DEV Community

Cover image for Where does a tap go in an offline-first Flutter app?
Stacklyn Labs
Stacklyn Labs

Posted on

Where does a tap go in an offline-first Flutter app?

Picture a field worker in a basement with no signal. They fill in a long inspection form and tap Submit.

In a cloud-first app, that tap starts a network request. The user watches a spinner, then an error, then fills the form in again later.

In an offline-first app, four things happen instead.

The four steps

  1. Instant write. The data goes straight to the on-device database. It is a local disk write, not a network call.
  2. Instant feedback. The UI shows success right away. No spinner, no "please try again".
  3. Queued. The sync engine flags the record as pending upload.
  4. Silent sync. When the device reconnects, the record uploads in the background.

The user never waits on the network, because the UI never talks to it directly. The local database is the source of truth; the server is something the app syncs with.

A minimal sketch in Dart

This is the shape of it, not a full implementation. The model carries a flag that says whether the server has it yet.

class Inspection {
  Inspection({required this.id, required this.data, this.pendingUpload = true});

  final String id;               // created on the device, e.g. a UUID
  final Map<String, dynamic> data;
  bool pendingUpload;
}
Enter fullscreen mode Exit fullscreen mode

Submitting only touches local storage:

Future<void> submit(Inspection inspection) async {
  await box.put(inspection.id, inspection); // 1. instant local write
  // 2. the UI listens to the box, so it updates immediately
  // 3. pendingUpload is already true, so the record is queued
}
Enter fullscreen mode Exit fullscreen mode

Sync runs separately, whenever connectivity comes back:

Future<void> syncPending() async {
  final pending = box.values.where((i) => i.pendingUpload);
  for (final item in pending) {
    await api.upload(item);        // 4. silent background upload
    item.pendingUpload = false;    // mark synced only after the server confirms
    await box.put(item.id, item);
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter more than they look:

  • The ID is created on the device. The record exists before the server ever sees it, so it can't wait for a server-generated ID.
  • "Synced" is set only after the server confirms. If the upload fails halfway, the record stays pending and is retried. The server should treat a repeated upload of the same ID as the same record, not a new one.

The hard part is sync, not storage

Writing locally is easy. The real design work is deciding what happens when several devices edit the same data offline and then reconnect.

  • Last write wins (the most recent timestamp takes priority) works well when users mostly edit their own data.
  • CRDTs (conflict-free replicated data types) guarantee that concurrent edits can always be merged, which suits heavily collaborative data.

Pick the strategy up front, per type of data. It shapes your models and your server API.

Why you can't bolt it on later

An app designed cloud-first has network calls inside its screens, server-generated IDs, and no idea of "pending". Adding offline caching on top of that usually produces a fragile mess. Offline-first is a decision about the data layer, and it is far cheaper on day one than as a retrofit.


Further reading: The offline-first architecture blueprint on our blog covers local-first vs offline-first and the sync problem in more depth.

Do you build offline-first by default, or only when a client asks for it?

Top comments (0)