Flutter App Lifecycle Explained: What You Need to Know

Understanding your Flutter app's lifecycle is what separates an app that quietly loses data when the user switches apps from one that doesn't. This guide walks through the actual states — all five of them, not the four still cited in most tutorials written before Flutter 3.13 — and shows each best practice wired into real code rather than just named.
Overview: the five lifecycle states
A Flutter app moves through a fixed set of states, driven by user actions (switching apps, taking a call) and system events (the OS reclaiming memory).
Resumed: the app is in the foreground and interacting with the user — this is the only state where your UI is actually visible and receiving input.
Inactive: the app is in the foreground but not receiving input. This is more common than it sounds — it fires during an incoming call, when the user pulls down the notification shade or opens the app switcher, and, critically, whenever a native permission dialog, a picker, or a system sheet is shown on top of your app. That last case is a real gotcha: your app hasn't actually gone anywhere, but it will still report inactive, so code that assumes "inactive means the user left" will misfire the moment you request camera or location permission.
Hidden: the app is no longer visible to the user, but hasn't yet been fully backgrounded — this state was added in Flutter 3.13 and sits between inactive and paused in the normal backgrounding sequence. Most guides written before that release still describe only four states; if you're checking against the current AppLifecycleState enum, this one is real and worth handling explicitly rather than letting it silently fall into a default branch.
Paused: the app is running in the background and not visible. This is where the OS may reclaim resources or, eventually, kill the process outright — it's the most important state for saving data you can't afford to lose.
Detached: the Flutter engine is running without an attached view. This isn't exclusively a shutdown signal — it can also occur briefly during app startup, before the first view has attached, so code that assumes detached only ever fires once, at the very end of the app's life, can be surprised by it appearing near launch too. Treat it as "no view is currently attached to the engine," not strictly "the app is dying."
Observing lifecycle changes
WidgetsBindingObserver is the standard mechanism, and the two calls that matter most are the ones bookending it — addObserver in initState, removeObserver in dispose — since skipping the second one leaks the observer the same way an unclosed StreamSubscription does:
class _MyAppState extends State<MyApp> 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) {
super.didChangeAppLifecycleState(state);
debugPrint('Lifecycle state: $state');
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Flutter App Lifecycle')),
body: const Center(child: Text('App Lifecycle Demo')),
),
);
}
}
A worked example: saving and restoring state correctly
Here's the version that actually does something, rather than printing the state name — a screen with an in-progress form that needs to survive the user getting a phone call mid-edit:
class _EditNoteScreenState extends State<EditNoteScreen> with WidgetsBindingObserver {
final _textController = TextEditingController();
Timer? _syncTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_restoreDraft();
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) => _syncToServer());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_syncTimer?.cancel();
_textController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
// Foreground but interrupted — could be a permission dialog, could be
// the start of backgrounding. Cheap to save here; too early to pause work.
_saveDraftLocally();
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
// Genuinely backgrounded now — stop anything that shouldn't run
// while the user can't see it, and make sure the draft is safe.
_saveDraftLocally();
_syncTimer?.cancel();
case AppLifecycleState.resumed:
// Back in the foreground — resume periodic work and check for
// anything that changed while we were away.
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) => _syncToServer());
_checkForRemoteChanges();
case AppLifecycleState.detached:
// No view attached — could be startup or shutdown. Save defensively
// either way; there's no harm in an extra local save.
_saveDraftLocally();
}
}
void _saveDraftLocally() {
localStorage.write('draft_${widget.noteId}', _textController.text);
}
Future<void> _restoreDraft() async {
final saved = await localStorage.read('draft_${widget.noteId}');
if (saved != null) _textController.text = saved;
}
Future<void> _syncToServer() async {
await noteRepository.save(widget.noteId, _textController.text);
}
Future<void> _checkForRemoteChanges() async {
final remote = await noteRepository.fetch(widget.noteId);
if (remote.updatedAt.isAfter(widget.lastKnownUpdate)) {
// Handle a conflict — someone else edited this note while we were away.
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit Note')),
body: TextField(controller: _textController, maxLines: null),
);
}
}
Notice inactive and the backgrounded states (hidden/paused) are handled differently, on purpose: saving a draft is cheap and safe to do on every inactive transition, even the ones caused by a permission dialog that immediately returns to resumed — but tearing down the sync timer only happens once the app is genuinely backgrounded, since doing that on every inactive would mean a permission prompt momentarily and pointlessly kills a timer that's about to be needed again a second later.
Best practices, made concrete
Save critical data during inactive, not just paused. Because inactive can be the last state you see before a hard kill on some platforms and situations, and it's cheap to write to local storage, saving early costs little and protects against interruptions that never reach paused at all.
Pause expensive, user-visible work specifically at hidden/paused, not inactive. Animations, video playback, and timers should stop once the app is actually backgrounded — stopping them on every inactive transition means visibly pausing and restarting for every permission dialog or system sheet, which reads as a bug to the user even though it's technically correct lifecycle handling.
Treat detached as "no view attached," and save defensively rather than assuming it's the last thing that will ever happen — since it can appear at startup too, code here should be idempotent and safe to run more than once.
Resume and reconcile at resumed, not just restart what was paused — check whether the data on screen might now be stale, the way _checkForRemoteChanges() does above, since time passed while the app was backgrounded and the world may have moved on without it.
Platform differences worth knowing
Android apps are more likely to be killed outright while backgrounded, especially on memory-constrained devices — treat paused as "this might be the last code that runs" rather than assuming detached will reliably follow it. iOS's lifecycle is more predictable, but inactive fires more readily there — Face ID prompts, the app switcher, and Control Center all trigger it, which is exactly the permission-dialog gotcha above, more pronounced on iOS than Android.
Common use cases
Push notifications can bring the app from resumed to inactive and back in quick succession as the notification banner is handled — this is a case where treating inactive as "pause everything" would cause visible, unnecessary flicker.
Background tasks like location tracking or media playback need explicit handling at paused, since Dart code stops running once the app is fully backgrounded on most platforms — genuinely persistent background work needs a platform-specific mechanism (WorkManager on Android, background modes on iOS), not just a lifecycle callback that keeps a Timer running, since that timer will not fire once the app is suspended.
State restoration is what the worked example above does end-to-end — save on interruption, restore on relaunch, and reconcile with the server on resume rather than trusting that nothing changed while the app was away.
Conclusion
The lifecycle states aren't just five labels to switch on — inactive and the backgrounded states solve genuinely different problems, and conflating them (pausing everything on inactive, or only saving data at paused) is where lifecycle bugs actually come from. Handle the five states for what they specifically mean, wire the handling into real save/resume logic rather than a print statement, and the app lifecycle stops being a source of silent data loss and starts being one of the more reliable parts of the app.
References





