Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
  1. Home/
  2. Writing/
  3. Flutter
Flutter

The cost of state restoration

Bringing the app back to where the user left it. The cheap version vs the expensive version.

February 18, 2026·7 min·by Ali Yosef

Loading essay…

ShareXLinkedInRSS
Get the next essay in your inbox.New posts every Sunday morning. Engineering, in plain language. Free, unsubscribe anytime.Subscribe→
AY
Ali Haitham Yosef

Engineer and writer. Builder of multi-tenant platforms, sync engines, and quietly reliable systems. 600+ shipped projects since 2015.

SubscribeStart a project→

Read next

  • OF
    Series
    Mar 6, 2026·Flutter

    Offline-first Flutter, part one: the boot

    Series opener. What the app does in its first 800ms when there is no network.

    13 min
  • DM
    Feb 26, 2026·Flutter

    Drift migrations without losing data

    The before, the during, the rollback. Pre-flight checks that have saved us five times.

    9 min
  • RB
    Feb 10, 2026·Flutter

    Riverpod vs Bloc, a year later

    Receipts from production. Where each fits, where each fails, and what we wish someone had told us.

    10 min
←Back to all essays

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.

What "state restoration" actually covers#

Three different things people lump under the same name:

  1. Navigation state. Which route is the user on. Easy to handle: serialise the current route + params on every navigation.
  2. In-progress form data. The user typed a phone number and an address; if we lose that, they will not type it again. Harder.
  3. Scroll positions and UI affordances. The list was scrolled to row 247; a sheet was half-open; a filter was applied. The cheapest to skip and the most missed when you do.

Each one has a different cost profile. We optimise them separately.

The cheap version#

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:

  • A user typing in a 12-character phone number triggers 12 writes.
  • On a low-end Android, each write hits flash storage and consumes battery. The app's CPU profile shows persistent low-level activity whenever a form is open.
  • The disk wears faster than it should. Most users never notice; some do.

The fix is throttling writes. The mistake is assuming throttling solves the problem.

What throttling alone does not solve#

A debounced "save every 250ms" reduces the write volume by 10-20×. It does not change the picture for two reasons:

  1. The write still happens on a timer. The user can be doing nothing and we are still writing the same state we wrote 250ms ago.
  2. The write contains the entire form. A 50-line invoice with 8 line items serialises to a few KB; we are writing those KB to disk even when only the customer name changed.

We needed to make writes both rarer and smaller.

The shape we ship now#

Three changes:

1. Persist on lifecycle events, not on every keystroke#

The Flutter WidgetsBindingObserver exposes lifecycle events. We hook the form's controller into them and write only when:

  • The app goes to the background (AppLifecycleState.paused).
  • The user navigates away from the form (route pop / replace).
  • An explicit "save draft" button is pressed.
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.

2. Persist diffs, not the whole form#

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.

3. Drop scroll positions on a different schedule#

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.

What we deliberately do not restore

Bottom sheets that were half-open. Modal selectors. The keyboard state. Any "ephemeral" UI that was up at the moment of pause is dismissed on restore. Restoring these introduces edge cases out of proportion to user value: the user knows what they were doing, they do not need the OS-killed snapshot to come back exactly. Navigation

  • form data + scroll is enough.

Measuring whether it works#

Two metrics:

  1. Restoration miss rate. Percentage of pause-resume cycles in which the user's typed data was lost. Ours sits at < 0.1 %. The misses are app crashes between persist and disk-flush, which we accept.
  2. Battery overhead from drafts. A separate trace tracks bytes written per session. We aim for under 8 KB per typical 5-minute form session. We currently hit ~3 KB.

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.

The principle#

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.