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

Offline-first Flutter, part one: the boot

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

March 6, 2026·13 min·by Ali Yosef
Part ofoffline-flutter→

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

  • 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
  • SR
    Feb 18, 2026·Flutter

    The cost of state restoration

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

    7 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 opens the app on a phone with no signal. What happens in the first 800 milliseconds? On a "cloud-first" app: a spinner. On an offline-first app, the answer should be "everything that worked yesterday." Getting from the first to the second is mostly about what you do at boot, not what you do at runtime.

This is part one of four on building Flutter apps that respect bad networks.

The boot sequence the docs do not write down#

Pretty much every Flutter tutorial shows you runApp(MyApp()) and moves on. In an offline-first app, that one line wraps a sequence of operations that have to happen in the right order or the user lands on a broken screen. Here is the sequence we use:

T+0ms     main() starts
T+10ms    WidgetsFlutterBinding.ensureInitialized()
T+20ms    Open Drift database (local schema)
T+50ms    Run pending migrations (almost always empty)
T+60ms    Load auth state from secure storage
T+80ms    Resolve tenant context (from auth state)
T+100ms   Apply theme (from local user_prefs row)
T+110ms   runApp(): UI tree mounts
T+150ms   First frame paints
T+200ms   Background sync attempts to start (network optional)
T+800ms   Visible "synced just now" if network was up

Two things are non-obvious. First, everything before runApp must be async-aware. If any step blocks for more than a few hundred ms, the user sees a black screen. Second, the network is never in the boot critical path. The app must be fully usable before the network is even checked.

What lives on the device#

Three layers, each with a different durability story:

┌────────────────────────────────────────────────────────┐
│ Drift (SQLite)         everything domain-shaped         │
│   contacts, invoices, products, sync_queue, audit_local │
├────────────────────────────────────────────────────────┤
│ Hive / SharedPrefs     small KV settings                │
│   theme, locale, last_sync_per_entity, feature_overrides│
├────────────────────────────────────────────────────────┤
│ FlutterSecureStorage   cryptographically protected      │
│   refresh_token, biometric_unlock, encryption_key       │
└────────────────────────────────────────────────────────┘

We never put domain rows in Hive. Hive is fast but has weak transactional guarantees and no schema migrations worth the name. When something is "data" (i.e. could ever be queried, updated, or related), it goes in Drift. When it is "knob" (a setting, a flag), Hive is fine.

The boot code, annotated#

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 1. Open the local database. This MUST succeed; if it does not,
  //    the app shows a "database error" splash and never reaches runApp.
  final db = await openLocalDatabase();

  // 2. Migrations. Drift handles the schema-version diff.
  //    A failed migration here is a "wipe and reinstall" event.
  try {
    await db.runMigrations();
  } on MigrationFailedException catch (e) {
    runApp(MigrationFailedSplash(error: e));
    return;
  }

  // 3. Auth + tenant. Network not contacted; we read the cached state.
  final authState = await SecureStorage.readAuthState();
  final tenant = await db.userPrefs.currentTenant();

  // 4. Theme. From local prefs; default to system if unset.
  final theme = await db.userPrefs.themePreference();

  // 5. Now we have everything we need to mount the tree.
  runApp(AppRoot(
    db: db,
    initialAuth: authState,
    initialTenant: tenant,
    initialTheme: theme,
  ));

  // 6. Background work. Fire-and-forget; never blocks the UI.
  unawaited(SyncService.attachAndCatchUp(db, authState));
}

The unawaited(...) on step 6 is intentional. The app is fully mounted and interactive before sync attempts to start. If sync is slow, the user is already typing. If sync fails, the user is already working from local data.

What "the app must be usable" actually requires#

Three guarantees:

  1. Every read query has a local fallback. No screen ever depends on a network response to render its primary content. Either the data is in Drift, or the screen has an empty-state design.
  2. Every write is queued before the user sees confirmation. "Saved" means "saved locally." If the user closes the app and comes back, the row is there.
  3. The sync state is visible. A small unobtrusive indicator (footer pill or top-bar dot) tells the user whether they are synced, syncing, or offline. Hiding the state breeds mistrust the first time something goes wrong.
The hidden cost of point 1

Reading exclusively from local first means every list view, search, filter, and detail page has to be backed by indexed Drift queries. Drift's API is good but the indexing decisions are yours: a missing composite index on contacts(tenant_id, name COLLATE) shows up as a 60ms freeze when the user types a letter into search. Plan for this.

Where we save time at boot#

Two optimizations matter:

Lazy-loading non-critical Drift tables#

Drift opens fast, but registering 40 tables takes proportional time. We split tables into "boot-critical" (auth, prefs, current tenant) and "on-demand" (audit, deleted_records, sync_queue) and lazy-open the second group when first queried. Cuts boot time by ~120ms.

Theme from prefs, not from a JSON parse#

We store the theme as a column on user_prefs, not as a JSON blob. Reading a single column from SQLite at boot is ~3ms; parsing a JSON blob and validating it is ~30ms. On the boot path that gap is visible.

No async dependency injection#

We pass the database instance through constructors, not through a get_it-style locator that has to wait for setupAsync(). Every async indirection at boot adds a microtask hop. Microtask hops add up.

What's next#

Part two of this series is about Drift migrations themselves: the patterns that survive a year of schema edits without losing data, the patterns that do not, and the pre-flight check we run before every release.

Series page tracks the rest.