← Back to home
← Back to the blog
5 min read· DirtFleet team

Offline-first for the yard: how DirtFleet's outbox actually works

A 30-second hours log shouldn't fail because the cellular signal is missing. Here's the IndexedDB outbox + idempotency-key dedupe that makes drivers trust the app on the worst pour day.

The pit is in a draw. The yard is on the other side of a hill. Your driver is in a JD 9R cab pulling a 60-ton load up a haul road that has zero LTE bars. They tap submit on the hours log and expect it to work. Building a maintenance app that works in those conditions is what most fleet software gets wrong — including what we got wrong on the first cut. Here is how DirtFleet's offline outbox works now, and the two invariants that make it boring to operate.

The problem in one paragraph

The PWA caches the app shell on first install (service worker + Cache API), so the UI loads even with no signal. The hard part is mutations — the driver fills out a meter reading, taps submit, and we have to do something useful even when the network is dead.

The bad approach (we tried this)

Block on the network. Show a spinner. If the request fails, toast “Could not save.” The driver retries. The retry also fails. They stop trusting the app. By the end of the week the foreman is back to pencil + a pad of paper. We burned three customers on this design before we ripped it out.

The good approach

Two pieces:

  1. IndexedDB outbox. Every mutation gets stamped with a client-side clientMutationId (UUIDv7 — sortable + unique) and persisted to a local IndexedDB table called outbox BEFORE we attempt the network call. The UI optimistically updates as if the server had accepted.
  2. Idempotency-key dedupe at the API. The server enforces a unique constraint on (organizationId, clientMutationId) per table. Replays — whether triggered by network retry, app reload, or browser tab restoration — collide harmlessly on that unique key and return the original row id.

A background drainer wakes up on every online event, every navigation, and every periodic timer (when the Page Visibility API says the tab is foregrounded). It POSTs the oldest queued row, removes it on success, and leaves it in place on failure for the next attempt.

Why the idempotency key matters

Without it, every retry has a chance of double-writing. A driver logs 1500 hrs, the request times out at the gateway (server actually wrote it), the outbox retries on reconnect, and now the asset has two log rows for the same shift. Now the cost-per-hour math is wrong, the PM threshold fires twice, and the next driver sees a confusing entry from themself.

The Postgres unique index closes the loop:

@@unique([organizationId, clientMutationId])  // on HoursLog

Server inserts; if Postgres throws P2002 we re-fetch by the unique key and return the existing id. Idempotent semantics without a per-route mutex.

What we don't do

  • Conflict-free replicated data types (CRDTs). Yjs / Automerge are gorgeous; they're also a six-month rewrite of the data model for a problem that idempotency keys solve in 50 lines. We'll revisit when collaborative editing becomes a use case (it isn't today).
  • Background sync via the Background Sync API. Unsupported on iOS Safari (and Chrome / Firefox treat it as best-effort). We rely on foreground-tab triggers + the existing service worker. When we wrap in Capacitor (see /docs), the native shell's background tasks plugin gets us proper OS-level wake-ups.
  • Photo bytes in the outbox. Photos go straight to the photo upload endpoint as a separate request — the outbox row stores a photoTempId that the API resolves to a real photo id post-upload. Cuts queue rows from megabytes to bytes.

What the user sees

A subtle “Pending sync · 3” pill in the header. Tap it to see the outbox contents. We don't toast on every successful drain — silence is the goal — but we DO toast when something fails for the third time so the user knows manual intervention may be needed (rare; usually a payload validation issue we can fix server-side and let the retry sweep up).

The code

lib/offline-hours-outbox.ts on the client; HoursLog.clientMutationId + the route handler in app/api/log-hours/route.ts on the server.

→ Product overview · → Start free trial