Offline-first Flutter, part one: the boot
Series opener. What the app does in its first 800ms when there is no network.
Part ofoffline-flutterLoading essay…
Series opener. What the app does in its first 800ms when there is no network.
Part ofoffline-flutterLoading essay…
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.
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.
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.
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.
Three guarantees:
Two optimizations matter:
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.
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.
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.
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.