Skip to main content

Blog

Offline-First Mobile Apps: The Outbox Pattern

How to build a mobile app that works on a bad connection — a durable, prioritised outbox, choosing local storage, conflict resolution, and the failure cases that decide whether users trust it.

4 min read

Users do not experience your app on your office wifi. They use it on a train, in a lift, on a hotel connection that resolves DNS but moves no data. If your app's answer to that is a spinner and a failed request, it feels broken — even though the network, not the app, is at fault.

Offline-first is the alternative: the app writes to local storage first, shows the result immediately, and syncs in the background when it can. Getting that right is mostly about what happens when the connection comes back.

Optimistic UI is not offline-first

A lot of apps described as offline-first are really just optimistic. They show the message as sent, fire the request, and quietly drop it if it fails — or worse, retry it forever in a loop that drains the battery and occasionally sends it twice.

Genuinely offline-first means the local database is the source of truth for the UI, every mutation is recorded durably before it is attempted, and sync is a separate process that reconciles local state with the server. The user sees local state. The network is an implementation detail.

The outbox pattern

The durable piece is an outbox: a local table of pending operations, each with a type, a payload, a status and a retry count. Instead of calling the API, the app writes to the outbox and returns. A worker drains it.

Why this beats retrying in place

  • It survives a cold start. If the user force-quits mid-send, the operation is still in the database when they come back.
  • It keeps ordering meaningful. A reply cannot sync before the message it replies to.
  • It makes failures visible. A row with five failed attempts is a thing you can surface, retry, or let the user cancel — rather than a request that vanished.

Not all operations are equal

This is where a naive queue disappoints. A chat message the user just typed and a profile-picture upload are not equally urgent, and draining strictly in insertion order means the message waits behind a multi-megabyte image on a weak connection.

Give each operation a priority and drain the queue by priority first, order second. On Chattick — an encrypted messaging app where the entire product is the speed of a sent message — that priority-based outbox is what keeps sending responsive while bulk media syncs behind it.

Choosing local storage

The local store is the thing the UI reads on every frame, so it needs to be fast, queryable and capable of feeding reactive streams. In a Flutter app that usually means an embedded database rather than key-value preferences, which run out of road as soon as you need to query, sort or watch a collection.

Two properties matter more than raw benchmarks: whether it can stream query results into your UI so screens update automatically as sync writes land, and whether it holds up with a realistic dataset — years of messages, not the twenty rows in your test fixture.

The hard part: conflicts

Two devices edit while offline. One wins. Decide which, deliberately:

  • Last write wins is simple and adequate for single-user data like settings or drafts, as long as your timestamps come from a source you trust.
  • Server authority — the server rejects stale writes and the client reconciles — suits anything involving money, inventory or bookings, where silently overwriting is unacceptable.
  • Append-only models dodge conflicts entirely. Messages, events and ledger entries are inserts, never edits, which is why chat is one of the easier things to sync correctly.

Whatever you choose, write it down. Undocumented conflict behaviour becomes a bug report that nobody can reproduce.

Details that decide whether users trust it

  • Idempotency. Every queued operation needs a client-generated ID the server can deduplicate against. Without it, one flaky connection turns into two identical orders.
  • Backoff. Retry with exponential backoff and a cap. Tight retry loops on a dead connection are a battery complaint waiting to happen.
  • Honest status. Show sending, sent and failed states. Users forgive a delay they can see; they do not forgive a message that silently never arrived.
  • Bounded growth. Decide what happens after a week offline. Cap the queue, expire stale operations, and never let the outbox grow without limit.
  • Test the ugly cases. Airplane mode is the easy test. The instructive ones are a connection that accepts requests and never responds, a token that expires mid-sync, and a force-quit halfway through draining the queue.

When not to do this

Offline-first adds a local schema, a sync worker, conflict rules and a class of bugs that only appear on real devices. It is worth it for messaging, field tools, travel apps and anything used somewhere with poor coverage.

It is not worth it for a product that is meaningless without the network — live scores, a video call, a payment terminal. There, the right investment is honest connection states and fast recovery, not a sync engine.

If you are weighing it up for your own product, it is exactly the kind of architectural decision we settle during MVP discovery, before it becomes expensive to change.

Frequently asked questions

What is the outbox pattern in mobile apps?
A local table of pending operations that the app writes to instead of calling the API directly. A background worker drains it, retrying with backoff. Because the operations are stored durably, they survive app restarts and can be ordered, prioritised and surfaced to the user.
Is offline-first worth the complexity?
For products used in poor connectivity — messaging, field tools, travel, logistics — yes, because the alternative is an app that feels broken on a train. For products that are meaningless without a live connection, such as video calls or payments, clear connection states are a better investment.
How do I stop duplicate writes when syncing?
Give every queued operation a client-generated identifier and have the server deduplicate on it. Without idempotency, a retry after an ambiguous timeout can create the same record twice.
How should conflicts be resolved?
Pick deliberately per data type: last write wins for single-user data like settings, server authority for anything involving money or inventory, and append-only models for messages and events, which avoid conflicts entirely.
What should I test beyond airplane mode?
Connections that accept requests but never respond, tokens expiring mid-sync, force-quitting while the queue drains, and a device that has been offline long enough to accumulate a large backlog.

Ready to scope your MVP? Get in touch or explore our portfolio, or grab the free MVP scoping checklist.

Ready to build something remarkable?

Book a free 20-minute scoping call. We'll review your idea, share relevant case studies, and outline a realistic timeline.

Reply within 24 hours · No commitment required