Flutter State Restoration: Surviving Process Death on Android and iOS
How Flutter state restoration works: RestorationScope and restorationScopeId, RestorationMixin with RestorableProperty, restoring the navigation stack with restorablePush, instance state versus long-lived state, and how to actually test that restoration works.
Published on • October 10, 2026
AI Assistant

Your app did not crash. The user swiped it away, or the OS quietly reclaimed memory while they switched to a map app, and when they came back your form was empty and they were on the home screen instead of checkout. Nothing threw an exception. Nothing showed up in your crash dashboard. The user simply decided your app lost their work and stopped using it.
This is process death, and Flutter has a first-class mechanism for handling it — RestorationManager and the widget-layer APIs built on top of it. Most apps never wire it up, either because they assume setState is enough or because they conflate ephemeral UI state with the data they should be persisting anyway.
This post explains what state restoration actually does, where the boundary sits between it and real persistence, and how to implement it correctly — including the part everyone skips, testing that it works.
In this tutorial, you will learn how to:
- Distinguish instance state, restoration state, and long-lived storage
- Enable restoration with
restorationScopeIdonMaterialApp - Make a custom widget restorable with
RestorationMixinandRestorableProperty - Restore the navigation stack using the
restorable*Navigator APIs - Configure Android and iOS so restoration is actually enabled
- Test restoration by forcing the OS to drop your app’s process
Key technologies: RestorationScope, RootRestorationScope, RestorationMixin, RestorableProperty, RestorationManager, RestorationBucket, restorablePush.
Prerequisites
- A Flutter app with a multi-screen navigation stack and at least one form
- An Android device or emulator and, optionally, an iOS device
- Comfort with
StatefulWidgetand mixins
Three kinds of state, and why the distinction matters
Flutter’s documentation is explicit that you should separate three categories, and getting this wrong is the main reason restoration gets a bad reputation:
Instance state (ephemeral) — unsubmitted form field values, the currently selected tab, a scroll offset, a partially expanded card. This is state that only matters while the widget exists and is what restoration is designed for.
Restorable state — the same category, but the values need to survive the process being killed and relaunched. Restoration is the mechanism.
Long-lived state — user accounts, saved documents, cart contents, settings. This belongs in a database or SharedPreferences, not in a restoration bucket. Restoration data is small, in-memory-ish, and tied to a single app session; it is not a persistence layer.
The rule of thumb: if losing it would lose work, persist it. If losing it would lose context, restore it. A half-written email body should be persisted. The tab the user had selected should be restored.
Enabling restoration
Restoration is opt-in at the app root. Provide a restorationScopeId:
MaterialApp(
restorationScopeId: 'app',
home: const CheckoutScreen(),
);
That single property injects a RootRestorationScope above your app class. If you need to restore state above the app widget — unusual, but possible with a custom shell — inject a RootRestorationScope manually.
With the scope in place, widgets that support restoration work out of the box. You enable them individually with a restorationId:
TextField(
restorationId: 'checkout_email_field',
controller: emailController,
)
TextField and ScrollView are the two that matter most in practice: text input and scroll position are exactly the “where was I” context users notice losing. Give every TextField in a multi-step flow a restorationId and you have solved most of the problem.
Making a custom widget restorable
For state the framework does not know about, you use RestorationMixin and RestorableProperty.
The pattern, from Flutter’s own counter sample:
class CounterState extends State<Counter> with RestorationMixin<Counter> {
// Hold the value in a RestorableProperty, not a plain int.
final RestorableInt _count = RestorableInt(0);
@override
String? get restorationId => 'counter_scope';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_count, 'count');
}
@override
void dispose() {
_count.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => setState(() => _count.value++),
child: Text('${_count.value}'),
);
}
}
There are four rules embedded in that snippet, and each one is a common source of “restoration does not work” bug reports:
1. The value lives in a RestorableProperty, not a plain field. RestorableInt, RestorableString, RestorableBool, RestorableDouble, RestorableValue<T>, RestorableEnum, RestorableDateTime, and RestorableController cover most cases. A plain int _count = 0; is invisible to the restoration machinery.
2. restorationId returns a stable, unique string per scope. If two sibling widgets return the same ID, their registrations collide.
3. Everything registers in restoreState. The mixin calls restoreState right after initState and again whenever new restoration data arrives. Registration is what pulls the stored value back in. A property registered once outside restoreState must be re-registered there on the next pass.
4. Dispose your properties. Every RestorableProperty must be disposed in State.dispose.
There is a subtle gotcha worth internalizing: initialization logic that depends on a restorable value must live in restoreState, not initState. Because restoreState can fire again later with a different value, code that reads _count.value during initState will read the default rather than the restored value.
// Wrong: runs once, before restoration
@override
void initState() {
super.initState();
_controller.text = _draft.value; // may be the default here
}
// Right: re-runs whenever the draft is restored
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_draft, 'draft');
_controller.text = _draft.value;
}
Restoring navigation
Restoring the widget tree without restoring where the user was gives you a half-fixed app. If the user was on the shopping cart, they should come back to the shopping cart.
Flutter handles this, but only if you use the Navigator APIs with restorable in the name:
| Instead of | Use |
|---|---|
push | restorablePush |
pushNamed | restorablePushNamed |
pushReplacement | restorablePushReplacement |
pushNamedReplacement | restorablePushNamedReplacement |
Navigator.of(context).restorablePushNamed('/cart');
These record the route and its arguments in the restoration bucket so the stack can be rebuilt on relaunch. Standard NavigatorState already implements restoreState internally — it registers the serializable route history — so the plumbing exists; you just have to call the right entry points.
If you use a routing package such as go_router, check its state restoration support rather than calling Navigator directly. Mixing the two is the usual way to end up with a restored tree and an unrecovered URL.
Instance state versus long-lived state, in practice
The Flutter Android docs draw the line like this:
Instance state (also called short-term or ephemeral state) includes unsubmitted form field values, the currently selected tab, and so on.
Ask what happens if this value is lost:
- Lost context, no lost work → restore. Selected tab, scroll position, search query in the box, the step of a wizard you are on.
- Lost work → persist. A draft message, an in-progress upload, anything the user spent time creating.
For the second category, restoration can still be a useful cache: write the draft to disk on change, restore from the bucket on relaunch for instant paint, and reconcile with the persisted copy once storage is ready. Restoration is fast and session-scoped; storage is durable and slower.
Platform configuration: it does nothing until you set this up
Restoration is disabled at the OS level until you opt in. This surprises almost everyone the first time.
Android
Set the app to not save state when backgrounded during testing (see below), and note that the RestorationManager provides restoration data to the engine as state changes, because when the OS signals it is about to kill the app you only have moments to prepare. That is why restoration data must be written proactively into buckets rather than serialized on demand — the OS asks for it on the platform thread and expects a synchronous response.
With a standard flutter create embedding, enabling restoration is:
- Set
android:enableOnBackInvokedCallbackand confirm your activity handlesonSaveInstanceStateas usual — the Flutter embedding does this for you. - Verify the app actually restores by forcing process death (next section).
iOS
A restoration identifier must be assigned to the FlutterViewController. With the standard embedding:
- Open
ios/Runner.xcworkspacein Xcode. - On iOS 14+, switch to profile or release mode — launching from the home screen is not supported in debug mode.
- Assign the restoration identifier in the ViewController configuration.
- Build, run from the home screen (not Xcode), background the app, and reopen it.
The debug-mode caveat on iOS is worth highlighting: you cannot validate iOS restoration from flutter run alone. Budget time for a profile-mode test on a physical device.
Testing that restoration actually works
Restoration testing requires the device to drop your app’s state when backgrounded. Both platforms can be configured to do this:
Android
Settings → Developer options → "Don't keep activities" → ON
Then: background your app, return to it, and confirm it restarts and restores.
iOS
Settings → Developer → "Fast App Termination" → ON
(or the equivalent state-deletion toggle for your iOS version)
Re-enable the setting when you are finished. The Flutter docs carry an explicit warning about this — leaving “don’t keep activities” on makes every app on the device behave badly, and you will spend an afternoon chasing phantom bugs that are entirely your testing configuration.
In an automated test
Widget tests cannot exercise the OS-level path, but you can verify the registration logic:
testWidgets('restores the draft when new restoration data arrives', (tester) async {
await tester.pumpWidget(
const MaterialApp(
restorationScopeId: 'app',
home: DraftScreen(),
),
);
await tester.enterText(find.byType(TextField), 'half-written note');
await tester.pump();
// Simulate restoration: pump the widget again inside a fresh scope.
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(
const MaterialApp(
restorationScopeId: 'app',
home: DraftScreen(),
),
);
expect(find.text('half-written note'), findsOneWidget);
});
This catches the classic bug — a RestorableProperty that was never registered — without needing a device. It does not catch OS configuration problems, which is why the manual device test above is not optional.
A worked example: a multi-step checkout
class CheckoutFlow extends StatefulWidget {
const CheckoutFlow({super.key});
@override
State<CheckoutFlow> createState() => _CheckoutFlowState();
}
class _CheckoutFlowState extends State<CheckoutFlow> with RestorationMixin<CheckoutFlow> {
final RestorableInt _step = RestorableInt(0);
final RestorableString _email = RestorableString('');
final RestorableString _address = RestorableString('');
@override
String? get restorationId => 'checkout_flow';
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_step, 'step');
registerForRestoration(_email, 'email');
registerForRestoration(_address, 'address');
}
@override
void dispose() {
_step.dispose();
_email.dispose();
_address.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _step.value,
children: [
EmailStep(
restorationId: 'email_field',
initial: _email.value,
onNext: (value) => setState(() {
_email.value = value;
_step.value = 1;
}),
),
AddressStep(
restorationId: 'address_field',
initial: _address.value,
onNext: (value) => setState(() {
_address.value = value;
_step.value = 2;
}),
),
ReviewStep(restorationId: 'review'),
],
),
);
}
}
Three restorable values, one of which is the step index — so the user returns to the exact screen they were on, with the fields they had filled in.
Common pitfalls
- Registering outside
restoreStateand never re-registering. Properties added after the initial call must be re-registered inrestoreStateon the next pass. - Reading a restored value in
initState. Move that logic intorestoreState. - Forgetting
dispose()on aRestorableProperty. It holds listeners; leaking them produces subtle bugs. - Using standard
Navigator.pushin a flow you want restored. Switch torestorablePush. - Testing only with
flutter runin debug mode. iOS restoration needs profile mode from the home screen; Android needs “don’t keep activities”. - Expecting restoration to replace persistence. It will not, and you will lose real data if you assume otherwise.