Pausing and Resuming Real Work: App Lifecycle Handling in Flutter
Wire timers, sockets, video, and sensors to AppLifecycleState — hidden vs inactive, platform state mapping, desktop exit requests, and the notifications you are never promised.
Published on • November 13, 2026
AI Assistant

flutter-widgets-binding-observer-lifecycle explains how to observe lifecycle changes. This post is about what to do inside them — the concrete work of pausing and resuming the things your app runs: timers, streams, sockets, video, animations, sensors, and location updates. It also covers the state-machine details the API docs spell out and most tutorials skip.
The state machine, honestly
AppLifecycleState has five values, and the docs warn that states not supported on a platform are synthesized so every platform shares one machine:
| State | Meaning | Entered on |
|---|---|---|
detached | Engine running with no views (also the default before init) | iOS, Android, web |
resumed | Visible, focused, default running mode | all platforms |
inactive | Visible, but no input focus | all platforms |
hidden | All views hidden — about to pause, or window/tab not visible | synthesized on iOS/Android before paused |
paused | Not visible, not responding to input; no onBeginFrame/onDrawFrame | iOS and Android only |
Two mapping facts from the AppLifecycleState reference that change how you branch:
- On Android,
Activity.onPausemaps to Flutter’sinactive, andActivity.onStopmaps topaused— Flutter’s names do not match Android’s one-to-one. - On the web,
inactivemeans the tab lost focus; there is nopausedstate in a browser tab.
So “app is in the background” is not one condition. If you stop work only on paused, you keep burning battery while an Android app is partially obscured but technically inactive. The safe rule the docs imply: assume inactive means you may be hidden or paused at any time.
One observer, every service
The pattern that scales is a single lifecycle hub instead of scattered observers:
class AppLifecycleHub extends StatefulWidget {
const AppLifecycleHub({super.key, required this.child});
final Widget child;
@override
State<AppLifecycleHub> createState() => _AppLifecycleHubState();
}
class _AppLifecycleHubState extends State<AppLifecycleHub>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
VideoPlayer.instance.resume();
SocketClient.instance.reconnect();
SensorHub.instance.start();
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
VideoPlayer.instance.pause();
SocketClient.instance.suspend();
SensorHub.instance.stop();
case AppLifecycleState.detached:
SocketClient.instance.close();
SessionStore.instance.flush();
}
}
@override
Widget build(BuildContext context) => widget.child;
}
Wrap your MaterialApp once and every service reacts in exactly one place. The modern alternative — AppLifecycleListener, with per-transition callbacks like onPause, onHide, and onExitRequested — fits the same hub if you prefer explicit callbacks over a switch.
What each resource actually needs
- Timers and animations: cancel on
hidden, recreate onresumed. ATimer.periodickeeps firing whilepaused— on iOS the OS tolerates it only briefly before throttling. - Video and audio: pause on
inactive, notpaused. A call interrupt (inactiveon iOS) should duck audio immediately. - Sockets and streams: suspend on
hiddenand reconnect onresumedwith backoff; networks get torn down while backgrounded, so assume the socket is dead, not idle. - Location and sensors: stop on
paused; if you genuinely need background updates, that’s an OS-level foreground service / background mode request, not a Flutter lifecycle branch. - Timed work that must survive: hand it to the OS — workmanager on mobile, or periodic work on the web — instead of trying to keep a Dart timer alive across suspension.
Desktop and web have their own edges
paused is only entered on iOS and Android — a minimized Windows app sits in inactive/hidden. On desktop, AppLifecycleListener.onExitRequested is your chance to veto a window close (save state, show a confirm dialog) before the app detaches. On the web, a closed tab simply goes away: detached can arrive with no warning.
And the docs are blunt about the guarantees: applications should not rely on always receiving all possible notifications. A task-manager kill, a kill signal, or power loss sends nothing — some states may be skipped entirely. Anything that must not be lost (drafts, cart, session tokens) needs flushing opportunistically — on inactive, on hidden, and whenever the OS gives you a grace period — rather than trusting a single terminal callback.
Test it without a device
Widget tests can drive the machine directly: handleAppLifecycleStateChanged(AppLifecycleState.paused) on the binding pushes your observer through the same code path the OS would. Assert that your hub paused the fake timer service on paused and resumed it on resumed — the whole discipline collapses if those transitions aren’t under test.
Takeaway: Treat inactive as “may be backgrounded any second,” wire every long-running resource through one lifecycle hub, flush what can’t be recreated before the OS kills you — because a killed process sends no notification at all.