The cost of state restoration
Bringing the app back to where the user left it. The cheap version vs the expensive version.
Loading essay…
Bringing the app back to where the user left it. The cheap version vs the expensive version.
Loading essay…
A user is filling out an invoice. The OS kills the app to free memory. The user comes back. They expect to be back on the invoice screen, with the typed values still there. The cheap way to deliver this breaks under load. The expensive way wastes battery. We've shipped both and ended up somewhere in the middle.
This essay is about what state restoration costs, and the trade-offs that determined where we landed.
Three different things people lump under the same name:
Each one has a different cost profile. We optimise them separately.
The version most apps ship is "save everything to disk on every keystroke." Concretely, in Flutter:
class InvoiceFormController extends ValueNotifier<InvoiceFormState> {
InvoiceFormController() : super(InvoiceFormState.empty()) {
addListener(_persist);
}
Future<void> _persist() async {
await Hive.box('drafts').put('current_invoice', value.toJson());
}
}Every change in the form fires a write to Hive. Hive is fast, so "fast" looks fine in development. In production:
The fix is throttling writes. The mistake is assuming throttling solves the problem.
A debounced "save every 250ms" reduces the write volume by 10-20×. It does not change the picture for two reasons:
We needed to make writes both rarer and smaller.
Three changes:
The Flutter WidgetsBindingObserver exposes lifecycle events. We hook
the form's controller into them and write only when:
AppLifecycleState.paused).class InvoiceFormController extends ChangeNotifier with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_persistDraft(); // fire and forget
}
}
}This single change cut our flash-write rate by ~95%. The user types freely; the form persists when they leave or the app backgrounds.
For long-form data (invoice with line items), we keep a lastSaved
snapshot and persist only the JSON-patch between current and last.
Reload reconstructs by applying the patch over the snapshot.
final patch = json_patch.diff(state.lastSaved, state.toJson());
if (patch.isNotEmpty) {
await Hive.box('drafts').put('invoice_${formId}', {
'snapshot_at': state.lastSavedAt,
'patch': patch,
});
}Average payload size dropped from 4 KB to 200 bytes. On a low-end device the difference is unmeasurable; in aggregate across thousands of users on slow flash it adds up.
Scroll position is high-frequency, low-importance data. Saving it on every scroll event is pointless; saving it on lifecycle events is late. We compromise: a 1-second debounce while the user is scrolling actively, persisted to memory only, and flushed to disk on lifecycle events alongside everything else.
Restoring scroll: on app resume, we wait for the first frame, then jump to the saved offset. If the list has been re-fetched and the offset no longer makes sense (rare), we pin to the closest known sticky header.
Two metrics:
Both numbers are dashboarded. The first time we shipped the diff-persist change, the bytes-written number dropped 20×, but we did not notice for two weeks because nobody was looking at it. Now we are.
State restoration is not "save everything"; it is "save the things the user would mind losing, at the moments where saving is cheap." The form data they typed: yes. The exact sub-pixel of their scroll position: no. Most of the cost in the cheap version comes from saving the wrong things.
The next part of this series is on Riverpod versus Bloc, with a year of production receipts. Series page tracks the rest.