DEV Community

Mohamed Haizoun
Mohamed Haizoun

Posted on

Offline-First Sync in Flutter with Drift and Riverpod

Most mobile apps are written as if the network is always there. It isn't. Users open your app in elevators, on planes, on rural roads, and in the ninety seconds before a train enters a tunnel. An offline-first app treats the local database as the source of truth and the network as a background detail — reads and writes never block on connectivity, and the server catches up when it can.

This tutorial builds a small task app that works fully offline and syncs to a remote backend when a connection is available. We'll use Drift for a reactive local SQLite layer and Riverpod to expose that data to the UI. By the end you'll have a pattern you can drop into a real app.

What "offline-first" actually means

Three rules define the architecture:

  1. The UI only ever reads from the local database. It never waits on an HTTP call to show data.
  2. Writes go to the local database first, then get queued for the server.
  3. A sync engine reconciles the two on a schedule, on reconnect, and on demand.

Everything below is in service of those three rules.

Setting up Drift

Add the dependencies:

dependencies:
  drift: ^2.16.0
  sqlite3_flutter_libs: ^0.5.20
  path_provider: ^2.1.2
  path: ^1.9.0
  flutter_riverpod: ^2.5.1
  connectivity_plus: ^6.0.1
Enter fullscreen mode Exit fullscreen mode

Define a tasks table. The two columns that make sync possible are updatedAt (for last-write-wins conflict resolution) and syncStatus (so we know which rows still need to be pushed). The important detail is that Drift's watch() returns a Stream that re-emits every time the underlying rows change — that stream is what makes the UI feel instant, because a local write updates the screen before any network call is even attempted.

Exposing data through Riverpod

Riverpod turns the Drift stream into something the widget tree can watch. There is nothing about HTTP in this layer — the UI has no idea the network exists.

final tasksProvider = StreamProvider<List<Task>>((ref) {
  return ref.watch(databaseProvider).watchTasks();
});
Enter fullscreen mode Exit fullscreen mode

Creating a task is a local write plus a status flag set to pending. The moment the write completes, watchTasks() fires and the list rebuilds. The user sees their task immediately, connection or not.

The sync engine

Now the interesting part: pushing pending rows to the server and pulling remote changes. The engine runs when connectivity returns and when the app asks it to.

Future<void> sync() async {
  if (_running) return; // never overlap two sync passes
  _running = true;
  try {
    await _push(); // send pending rows, mark them synced
    await _pull(); // fetch remote changes since last pull
  } catch (_) {
    // Swallow and retry next cycle — offline-first means failure is normal.
  } finally {
    _running = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two design choices do the heavy lifting:

  • The _running guard prevents two sync passes from racing when connectivity flaps — a surprisingly common source of duplicate writes.
  • Last-write-wins on updatedAt is the simplest conflict strategy that actually works for single-user data. If two devices edit the same row, the newer timestamp wins. For collaborative data you'd reach for CRDTs, but don't pay that complexity tax until you need it.

Where to go next

This pattern scales further than you'd expect. Add a deletedAt tombstone column so deletes propagate. Batch your push into a single request when the server supports it. Add exponential backoff to the catch block. But the core stays the same: local database is truth, network is a background job, and the UI never waits.

The full benefit shows up the first time a user files a bug that says "the app was slow on the subway" — and you realize your app doesn't have that bug, because it never touched the network to draw a screen.

Top comments (0)