← Back to blog
Offline-First Flutter · Part 1 of 12
September 27, 202612 min read

Offline-First Flutter, Part 1: Foundations & the FieldNotes Project

FlutterDartOffline-First

Offline-First Flutter: Foundations

Welcome to the Offline-First Flutter series — a hands-on, build-it-for-real course. Over the next parts we'll construct FieldNotes, a notes app that works perfectly with zero signal and quietly syncs to a server when the network returns. By the end you'll have built — not just read about — a complete sync engine: an outbox, delta pull, conflict resolution, background sync, and tests.

This first part is the map. We answer what offline-first is, why it's a different architecture (not just "add a cache"), and what we're building. Get the mental model here and every later part is just filling in a box you already understand.

Stack we'll use: Drift (reactive SQLite) as the local source of truth, Riverpod 3.0 for reactive UI, and an abstract REST backend so nothing is tied to a specific vendor. Flutter 3.38 / Dart 3.12. This series assumes you know widgets and have met state management.


What "offline-first" actually means

There's a spectrum, and the words matter:

| Approach | Behavior | Problem | | --- | --- | --- | | Online-only | Every action hits the network; no network → no app | Useless in a tunnel, elevator, plane, rural area | | Online-first + cache | Reads from network, falls back to a cache | Writes still fail offline; cache is an afterthought; "stale vs fresh" bugs everywhere | | Offline-first | Every read and write goes to local storage first; the network syncs in the background | The app never waits on the network and never blocks |

The defining rule of offline-first: the app never talks to the network to satisfy a user action. It reads and writes local storage, instantly, every time. Syncing with the server happens separately and asynchronously, and the user doesn't wait for it.

Analogy — the field notebook. Imagine a field researcher with a paper notebook. They write observations the instant they happen — no signal required, no waiting. Later, back at base, an assistant copies new entries into the head-office archive and brings back anything new from colleagues. The researcher never stops to ask the head office for permission to write a line. The notebook is the truth they work from; the archive is just where it eventually syncs.

That's offline-first. The local database is the notebook (always available, always written first). The sync engine is the assistant (reconciles with the server in the background). The UI talks only to the notebook.


Why bother? (the case is stronger than you think)

It's tempting to think "my users have wifi." But offline-first isn't only for no-signal scenarios — it's an architecture that makes apps feel instant and resilient everywhere:

  • Speed. Reading from a local SQLite row is sub-millisecond. Waiting on a network round-trip is 100–2000ms. Offline-first apps feel instant because they are — every screen renders from local data immediately.
  • Resilience. Flaky networks (subways, elevators, planes, basements, rural areas, conferences) don't break the app. Writes succeed; they just sync later.
  • Battery & cost. Batched background sync beats chatty per-action requests.
  • UX. No spinners on every tap. The user acts; the UI updates; sync is invisible.

Offline-first is how WhatsApp, Notion, Linear, Things, and most great mobile apps feel so snappy. They're not faster at networking — they don't network on the critical path at all.


The architecture: one diagram to rule the series

Here's the whole system. Every part of this series builds one of these boxes:

        ┌─────────────────────────────────────────────┐
        │                   UI (Widgets)                │
        │     reads reactive streams • dispatches edits │
        └───────────────▲───────────────┬──────────────┘
                        │ watch          │ create/update/delete
                 (Riverpod streams)      │
        ┌───────────────┴───────────────▼──────────────┐
        │                 Repository                     │
        │   the ONLY thing the UI talks to              │
        └───────────────▲───────────────┬──────────────┘
                        │ reactive query │ write + enqueue
        ┌───────────────┴───────────────▼──────────────┐
        │         LOCAL DATABASE (Drift / SQLite)        │
        │      ★ THE SINGLE SOURCE OF TRUTH ★            │
        │   notes table  +  outbox (pending changes)     │
        └───────────────▲───────────────┬──────────────┘
                        │ apply remote   │ read pending
        ┌───────────────┴───────────────▼──────────────┐
        │                 SYNC ENGINE                    │
        │  push outbox → server • pull deltas → local    │
        │  resolve conflicts • triggered by connectivity │
        └───────────────▲───────────────┬──────────────┘
                        │                │
        ┌───────────────┴───────────────▼──────────────┐
        │           REMOTE SERVER (abstract REST)        │
        └────────────────────────────────────────────────┘

Read it top to bottom and notice the two independent flows:

  1. The user flow (fast, synchronous, local): UI → Repository → Local DB. The user writes a note; it lands in SQLite; reactive streams update the UI. The network is not involved. This is steps you'll build in Parts 2–4.
  2. The sync flow (slow, asynchronous, background): Sync Engine ↔ Server, reconciling the Local DB with the remote. Triggered by connectivity and timers. This is Parts 5–10.

The single most important idea in this entire series: the local database is the single source of truth (SSoT). The UI never reads from the network. The sync engine's only job is to keep the local DB and the server eventually consistent. Internalize this and offline-first stops being scary.


The hard parts (and where we tackle them)

Offline-first is "just" local-first reads/writes plus background sync — but that background sync hides every interesting problem in distributed systems. Here's the honest list and where each lands:

| Challenge | The question it answers | Part | | --- | --- | --- | | Data modeling | What sync metadata does each row need? | Part 2 | | Schema migrations | How do I evolve the local DB after shipping? | Part 3 | | Reactive reads | How does the UI update instantly from local data? | Part 4 | | Connectivity | Am I really online (not just "wifi connected")? | Part 5 | | Pushing writes | How do I reliably send local changes once? | Part 6 | | Pulling changes | How do I fetch only what changed, efficiently? | Part 7 | | Conflicts | Two devices edited the same note — who wins? | Part 8 | | Optimistic UI | How do I show "syncing/synced/failed" cleanly? | Part 9 | | Background sync | How do I sync when the app is closed? | Part 10 | | Testing | How do I prove the sync engine is correct? | Part 11 |


Meet FieldNotes — the app we'll build

Our running project is FieldNotes: a deliberately simple domain (notes) so the architecture stays front and center.

Features:

  • Create, edit, and delete notes (title + body), fully offline.
  • Each note shows a sync status (synced / pending / failed).
  • Changes sync to a server automatically when online, and in the background.
  • Edits made on another device show up after sync.
  • Conflicts (same note edited in two places) are resolved sensibly.

The data model (preview — built properly in Part 2):

// A note, with the EXTRA fields that make sync possible:
class Note {
  final String id;          // a client-generated UUID (not a server auto-int!)
  final String title;
  final String body;
  final DateTime updatedAt; // when it last changed locally
  final bool isDirty;       // has un-synced local changes?
  final bool isDeleted;     // soft delete (a "tombstone"), not a real delete
  final int version;        // for conflict detection
}

Already a lesson: notice id is a client-generated UUID, not a server auto-increment. Offline-first apps must create records before the server ever sees them, so the client mints the id. And isDeleted is a soft delete — you can't just remove a row, because the server needs to learn it was deleted. These two decisions ripple through everything; we'll justify them fully in Part 2.


Why we hand-build the sync engine (and when not to)

There are excellent managed offline-first frameworks: PowerSync (SQLite ↔ Postgres/Supabase), Brick, and others that handle sync for you. They're great for production. So why build our own?

Because you can't trust — or debug — a sync engine you don't understand. Hand-building the outbox, delta pull, and conflict resolution once teaches you the exact trade-offs every managed framework is making under the hood. After this series you'll be able to (a) build sync yourself when you need full control, and (b) evaluate and debug a managed framework intelligently.

| Roll your own (this series) | Use a framework (PowerSync/Brick) | | --- | --- | | Full control, no vendor lock-in | Faster to ship | | You understand every edge case | Battle-tested sync/conflict handling | | More code to maintain | Less code, more magic | | Best for learning + custom needs | Best for standard CRUD-over-Postgres |

We build by hand. When you reach conflict resolution you'll appreciate exactly what the frameworks save you.


Practice Challenges

Challenge 1 — Classify the app. An app loads a feed from the network, shows a spinner, and fails to post when offline. Which approach is it, and what would make it offline-first?

Show solution

It's online-first (+ maybe a cache). To make it offline-first: write posts to a local database immediately (so they succeed offline), render the feed from local data, and have a background sync engine push local posts and pull new feed items when connectivity allows. The user action never waits on the network.

Challenge 2 — Name the SSoT. In an offline-first app, when the UI shows a list of notes, where does that data come from, and where does it never come from?

Show solution

It comes from the local database (the single source of truth) — always. It never comes directly from the network. The sync engine updates the local DB in the background; the UI only ever reads local data.

Challenge 3 — Spot the id smell. Why can't an offline-first app use server auto-increment integer ids as the primary key?

Show solution

Because records are created offline, before the server sees them — there's no server round-trip to allocate an id. The client must generate the id itself (e.g. a UUID) so the note has a stable identity from the moment it's created, surviving sync. Server auto-ints would collide or require a network call to create anything.

Challenge 4 — Why soft delete? Explain why deleting a note must set an isDeleted flag rather than removing the row.

Show solution

If you physically remove the row, the sync engine has no record that a deletion happened, so it can't tell the server (or other devices) to delete it too — the note would reappear on next pull. A soft delete / tombstone (isDeleted = true) keeps the deletion as syncable data; the row is purged only after the deletion has propagated.

Challenge 5 — Two flows. Describe the two independent data flows in the architecture and which one the user waits on.

Show solution

(1) User flow: UI → Repository → Local DB — synchronous, instant, local; the user waits only on this (milliseconds). (2) Sync flow: Sync Engine ↔ Server, reconciling local DB with remote — asynchronous, background, triggered by connectivity/timers; the user never waits on it.


Questions to test yourself

Q1 (basic). Define offline-first in one sentence.

Show answer

An architecture where every read and write goes to local storage first (instant, network-independent), and syncing with the server happens separately in the background — so the app never waits on or blocks for the network.

Q2 (basic). What is the single source of truth in an offline-first app?

Show answer

The local database (Drift/SQLite here). The UI reads only from it; the sync engine's job is to keep it eventually consistent with the server.

Q3 (intermediate). How does offline-first differ from "online-first with a cache"?

Show answer

A cache is a fallback for reads layered onto a network-primary design, so writes still fail offline and "stale vs fresh" logic is everywhere. Offline-first makes local storage primary for both reads and writes; the network is never on the critical path, and sync is a first-class background concern, not an afterthought.

Q4 (intermediate). Why does an offline-first app feel faster even on a good network?

Show answer

Because it reads/writes local storage (sub-millisecond) instead of doing network round-trips (100–2000ms) on the critical path. The UI updates instantly from local data; sync happens invisibly in the background, so there are no per-action spinners.

Q5 (advanced). Why are client-generated UUIDs and soft deletes both consequences of the offline-first principle?

Show answer

Because records are created and deleted offline, before the server is involved. A record needs a stable identity from creation, so the client mints a UUID rather than relying on a server-allocated id. A deletion is information that must sync, so it's recorded as a soft delete/tombstone rather than a row removal — otherwise the sync engine couldn't propagate the delete and the note would resurrect on the next pull.

Q6 (advanced). Why build the sync engine by hand instead of using a framework like PowerSync?

Show answer

To understand and be able to debug the exact trade-offs (outbox semantics, delta pull, conflict resolution) that any framework hides. Hand-building once gives you the judgment to roll your own when you need control/no lock-in, and to evaluate or troubleshoot a managed framework intelligently. Frameworks are great for shipping standard CRUD fast; understanding is great for everything.


Wrapping up

  • Offline-first = every read/write hits local storage first; sync is a separate background concern. The network is never on the critical path.
  • The local database is the single source of truth; the UI reads only from it; the sync engine keeps it eventually consistent with the server.
  • The architecture has two independent flows: the fast local user flow and the slow background sync flow.
  • It's not just for no-signal — it makes apps instant and resilient everywhere.
  • FieldNotes is our project; its model already forces two key decisions — client UUIDs and soft deletes — that we'll justify next.
  • We hand-build the sync engine to understand it; frameworks (PowerSync/Brick) are for later.

In Part 2 we design the data layer properly: the Drift schema and sync metadata — exactly which extra fields each row needs so it can be synced, conflict-detected, and deleted safely.