Flutter Offline-First Outbox Sync for a Field App

Photo by mikecogh via Openverse (CC BY-SA 2.0)
An outbox pattern stores every write, such as opening an order or recording a payment, as a row in a local SQLite table instead of calling the API directly. The UI reads back its own local write instantly, and a separate background process drains the outbox to the server when connectivity returns. This lets the app keep working with no signal at all.
connectivity_plus exposes a stream that emits whenever reachable network types change, and in recent versions it emits a list of results because a device can use several transports at once. The app subscribes to that stream and attempts to drain the outbox on any non-none event. The stream is only a hint, so the actual HTTP result still decides whether each row is done or must retry.
Each outbox row is created with a client-generated UUID that doubles as an idempotency key and stays stable across every retry. The Flutter app sends it as a header, and the NestJS settlement service stores the key with the result of the first successful application. Any repeat request bearing a seen key returns the stored result instead of settling again, so a timed-out retry can never create a second order.
The outbox drains in strict created-at order so causally linked writes, like an order before its payment, replay in the sequence the crew performed them. Most writes are appends that cannot conflict, and the few mutable fields carry a server version that rejects stale writes. Server invariants such as a derived cash book and stock tracked as additive movements remove entire categories of conflict.
Each feature defines one abstract repository contract with two implementations: an ApiRepository that talks to the NestJS backend through the outbox, and a MockRepository backed by local fixtures. Riverpod injects whichever is needed, so the app can run a full demo with no backend and QA can test every screen offline. Sharing one contract keeps offline behaviour a peer of the live path rather than a bolted-on special case.

Photo by mikecogh via Openverse (CC BY-SA 2.0)
Key Takeaway
An offline-first field app queues every write into a local SQLite outbox instead of hitting the API directly. connectivity_plus watches the network, a background worker drains the outbox when signal returns, and a client-generated idempotency key on each row guarantees the NestJS API applies every order exactly once even after retries.
JID Carwash runs across concrete wash bays where mobile signal dies the moment a car rolls under the roof. The Flutter field app my team shipped, built on Flutter 3.22 and Dart 3.4 with Riverpod and go_router, cannot assume a network exists when a crew member taps to open an order. If the app blocked on an HTTP call, the whole business would stall behind a spinner. So every write goes offline-first: the UI records intent locally, and the network becomes an implementation detail that happens later.
This post walks through the outbox pattern behind that behaviour: how writes are persisted locally, how connectivity_plus triggers a drain, how the NestJS API replays them idempotently, and why every feature in the app ships two repositories that share one contract.
The core idea is to never let the UI call the API. Instead, an action such as opening an order or recording a payment writes a row into a local SQLite outbox table. The row carries the operation type, a JSON payload, a client-generated identifier, a status, an attempt counter, and a created-at timestamp. The screen reads back its own local write immediately, so the crew sees the order appear whether or not a byte ever left the phone. Draining that outbox to the server is a separate, asynchronous concern.
Money is stored as integer IDR rupiah everywhere, never as a floating-point value. A wash priced at fifty thousand rupiah is the integer 50000. This matters doubly offline: a payload that sits in the outbox for twenty minutes must deserialize to the exact same amount the server bills, and integers survive JSON round-trips without the rounding drift that floats introduce.
// outbox row (sqflite) — one pending write, offline-first
CREATE TABLE outbox (
id TEXT PRIMARY KEY, -- client UUID, doubles as idempotency key
op_type TEXT NOT NULL, -- 'order.create' | 'payment.settle' | ...
payload TEXT NOT NULL, -- JSON; money fields are integer IDR
branch_id TEXT NOT NULL, -- every write is branch-scoped
status TEXT NOT NULL, -- pending | inflight | done | failed
attempts INTEGER NOT NULL DEFAULT 0,
next_retry_at INTEGER, -- epoch ms; backoff gate
created_at INTEGER NOT NULL
);
// draining respects creation order so replay is deterministic
final pending = await db.query(
'outbox',
where: "status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= ?)",
whereArgs: [DateTime.now().millisecondsSinceEpoch],
orderBy: 'created_at ASC',
);connectivity_plus, currently at version 7.3.0, exposes a stream that emits whenever the reachable network types change. In recent major versions that stream carries a list of results rather than a single value, because a phone can be on Wi-Fi and cellular at once. I treat any non-none entry as a signal to attempt a drain. The stream is only a trigger, though, never the authority. Connectivity reporting a transport does not prove the API is reachable, so the drainer still has to tolerate failures on the actual HTTP call.
A drain also fires on app resume and on a periodic timer, so a phone that was force-closed mid-shift still catches up. Each attempt increments the row's counter and, on failure, sets a next-retry timestamp using exponential backoff with jitter. That jitter is deliberate: fifteen crew phones reconnecting to the depot Wi-Fi at closing time would otherwise stampede the API in the same second. After a capped number of attempts a row moves to a failed state and surfaces in an admin screen rather than retrying forever.
A connectivity event is not proof of a working server. Treat the connectivity_plus stream as a hint to try, and let the HTTP result decide whether a row is done or must retry. Marking a row done just because the phone reported Wi-Fi is how you silently lose orders.
The classic offline failure is the request that succeeds on the server but whose response never reaches the phone. The app times out, retries, and now the same order exists twice. The fix is an idempotency key generated on the device the instant the outbox row is created. Because the key is the row's own identifier, it is stable across every retry of that row. The Flutter drainer sends it as a header; the NestJS settlement service records it and short-circuits any repeat.
On the server, payment settlement lives in a dedicated service that stores each idempotency key with the result of the first successful application. A second request bearing a seen key returns that stored result instead of settling again. This is the same guarantee card networks use, implemented with one indexed column and no distributed consensus.
// Flutter drainer — the key never changes across retries
final res = await httpClient.post(
'/orders',
headers: {'Idempotency-Key': row.id}, // == outbox.id
body: row.payload,
);
// NestJS settlement.service.ts — apply once, replay the stored result
async settle(dto: SettleDto, key: string) {
const seen = await this.repo.findByIdempotencyKey(key);
if (seen) return seen.result; // safe replay, no double-charge
const result = await this.applySettlement(dto);
await this.repo.saveKey(key, result);
return result;
}The idempotency key and the local row identifier are the same UUID. One value does two jobs: it is the primary key that lets a screen read back its own write instantly, and it is the token that makes the server-side replay safe. Fewer identifiers means fewer ways to get exactly-once wrong.
Draining in strict created-at order keeps causally linked writes correct. An order must be created before its payment settles, so if both sit in the outbox they replay in the order the crew performed them. When one row fails and blocks the queue, I do not skip ahead past a dependency; the dependent operation waits behind it rather than settling a payment against an order the server has never seen.
Conflict handling is deliberately narrow because the domain makes it easy. Most writes are appends that no one else touches: a new order, a new payment, a kasbon employee cash advance. Those cannot conflict, so they replay unconditionally. The few mutable fields carry a server-assigned version, and the API rejects a stale write so the app can refetch and let a human decide. Some server state is not even stored, which removes whole categories of conflict outright.
Two server invariants shrink the conflict surface further. The cash book is a derived union view computed on read, never a written table, so no two phones can ever disagree about its rows. And a product's stock quantity is only a cache; the true figure is the sum of stock-movement deltas. A field write appends a movement rather than overwriting a count, which means two crews depleting the same soap stock produce two additive movements, not a lost update.
Every feature exposes an abstract repository interface with two implementations: an ApiRepository that talks to NestJS through the outbox, and a MockRepository backed entirely by local fixtures. Riverpod chooses which one to inject. This is not only a testing convenience. The mock is a first-class runtime mode, so a demo at a prospective franchise site runs the full app with zero backend, and QA can walk every screen on an airplane. Because both implementations satisfy the same contract, offline behaviour is not a special branch bolted onto the live path; it is a peer of it.
The POS web admin mirrors this exactly with an httpClient and a mockClient sharing one types file, which means the two surfaces agree on the shape of an order before a single request is sent. Keeping the contract in one place is what lets the offline app and the online admin stay honest with each other.
| Concern | Naive online-only | Outbox offline-first |
|---|---|---|
| No-signal bay | Blocks on a spinner | Records locally, drains later |
| Retry after timeout | Risks duplicate order | Idempotency key dedupes |
| Write ordering | Whatever arrives first | Strict created-at replay |
| Demo without backend | Impossible | MockRepository runs full app |
The result is an app that treats the network as unreliable by default rather than as a happy path with error handling stapled on. Crews open, wash, and settle in dead zones all shift, and the orders arrive at the NestJS API intact, in order, and exactly once, whenever the signal decides to come back.