Skip to content
Blog

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:

StateMeaningEntered on
detachedEngine running with no views (also the default before init)iOS, Android, web
resumedVisible, focused, default running modeall platforms
inactiveVisible, but no input focusall platforms
hiddenAll views hidden — about to pause, or window/tab not visiblesynthesized on iOS/Android before paused
pausedNot visible, not responding to input; no onBeginFrame/onDrawFrameiOS and Android only

Two mapping facts from the AppLifecycleState reference that change how you branch:

  • On Android, Activity.onPause maps to Flutter’s inactive, and Activity.onStop maps to paused — Flutter’s names do not match Android’s one-to-one.
  • On the web, inactive means the tab lost focus; there is no paused state 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 on resumed. A Timer.periodic keeps firing while paused — on iOS the OS tolerates it only briefly before throttling.
  • Video and audio: pause on inactive, not paused. A call interrupt (inactive on iOS) should duck audio immediately.
  • Sockets and streams: suspend on hidden and reconnect on resumed with 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.

Sources