Most software is built on an assumption that quietly breaks in production: the network is up. It works fine in the office, on the demo wifi, in the investor pitch. Then a warehouse with concrete walls kills the signal, a retail location's ISP drops for twenty minutes during a storm, or a job site has one bar of LTE that comes and goes. The app freezes, spins, or worse, silently loses the write.
The instinct is to treat this as a networking problem: add retries, show a toast, maybe cache a few GET requests. That treats connectivity loss as an edge case. In the businesses we build for, it is the normal case. A retail counter processes sales whether or not the store's internet is up. A field crew logs hours whether or not the job site has coverage. If the software can't record that transaction locally and reconcile it later, it isn't offline-capable, it's just offline-broken with a nicer error message.
We built Retailer OS around this assumption from the start: point of sale that keeps ringing sales with no connection and reconciles inventory and payments the moment the connection comes back. That decision shaped the data model, the sync protocol, and how we handle conflicts, well before it shaped the UI. This post is that architecture, stripped down to the parts you can apply regardless of what you're shipping.
Stop designing for the happy path first
The standard SaaS request flow is: client sends a request, server validates, server writes, server responds, client updates its view. Every step in that chain assumes the network is present for the whole round trip. Offline-first inverts it: the client writes locally first, treats that write as durable and correct from the user's point of view, and only afterward worries about getting it to the server.
This is a bigger architectural change than it sounds like. It means your client needs its own durable storage, not just a cache. It means every write needs an identity that survives being sent twice. And it means your server has to accept writes that happened in the past, possibly out of order relative to other writes, and possibly in conflict with something that happened on a different device in the meantime.
Give every write an identity before it leaves the device
The single most useful primitive in offline architecture is the idempotency key generated on the device at the moment of the write, not by the server after the fact. Without it, a retry after a dropped connection can create the transaction twice, and now you're debugging duplicate sales instead of writing features.
A local outbox table is the simplest version of this that we've operated in production. Every user action that needs to reach the server becomes a row, written locally in the same transaction as the domain data it affects:
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- client-generated idempotency key
entity_type TEXT NOT NULL, -- 'sale', 'stock_adjustment', etc.
entity_id UUID NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
synced_at TIMESTAMPTZ,
attempt_count INT DEFAULT 0
);
The sync worker reads unsynced rows in creation order, posts them, and marks synced_at on success. If a post fails partway through, the client-generated id means the server can safely accept the same row twice without double-applying it. The server side just needs a unique constraint on that key per tenant.
This pattern generalizes past POS. Field service check-ins, expense submissions, inventory counts, anything a user does while disconnected can go through the same outbox shape. The entity-specific logic lives in how the payload gets applied server-side, not in the sync mechanism itself.
Decide your conflict rule before you need it
Two devices editing the same record while both are offline is not a rare failure mode, it's Tuesday. A manager adjusts stock on the sales floor tablet at the same time a receiving clerk adjusts the same SKU on the back-office terminal, both offline, both syncing twenty minutes later. Something has to win, and "whoever synced last" is not a decision, it's an accident.
Pick a conflict strategy per entity type, deliberately, and write it down:
- Last-writer-wins by field, not by record. Two edits to different fields on the same record should both survive, not clobber each other. Store timestamps per field if the record has more than one independently-editable field that matters.
- Commutative operations instead of overwrites. "Decrement stock by 3" is safe to apply twice in any order relative to other decrements. "Set stock to 47" is not. Model inventory changes as deltas, and let the server sum them, rather than shipping absolute values from the client.
- Server-side reconciliation for anything financial. Cash drawer counts, payment captures, and ledger entries don't get resolved by a merge rule, they get resolved by a reconciliation pass that flags the discrepancy for a human. Silent auto-resolution on money is how you end up explaining a shortfall you can't trace.
The mistake we see most often is treating conflict resolution as a client concern, solved with a clever merge function shipped in the app. Push it server-side wherever you can. The server sees every device's writes, the device only sees its own, and the server is the only place with enough information to actually detect a conflict rather than guess at one.
Design the sync payload like a queue, not a snapshot
A common early mistake is syncing by re-sending the client's current state and letting the server diff it against what it has. That works at small scale and falls apart once a device has been offline for hours and accumulated dozens of interleaved changes across several entities. Diffing full state loses ordering information you need for the commutative-operations approach above.
Sync the outbox as an ordered event stream instead: send each pending row in creation order, apply each one as its own transaction server-side, and only advance the client's sync cursor past events that were durably accepted. If the connection drops mid-batch, the client resumes from the last confirmed cursor, not from scratch. This also gives you a natural audit trail: the outbox rows are, in effect, a change log you can replay for support debugging or for building the kind of reconciliation report you'd want during a migration or an incident review.
On the read side, the device needs its own local copy of whatever it needs to keep functioning offline, not a live query against the server. That means your schema decisions upstream, particularly whether you run shared schema or a database per tenant, affect how much data you can reasonably replicate down to a device. A single-tenant slice of data is easy to mirror locally. A shared table filtered by tenant ID at query time is not something a device can trivially cache correctly offline; you need an explicit export or subscription boundary.
Where offline-first is the wrong answer
This architecture is not free, and it is not always the right call. If your write volume per entity is high and multi-writer contention is constant even when everyone's online, for example a shared queue that a dozen users hit every few seconds, offline-first conflict resolution adds complexity without solving a problem you actually have. Online-only with strong server-side locking is simpler and correct.
It's also the wrong default for anything where a stale local view is actively dangerous rather than merely inconvenient: real-time pricing that changes throughout the day, available-to-promise inventory shared across many locations, anything where acting on data that's twenty minutes old causes a worse outcome than just failing the request and telling the user to try again once connected.
Offline-first isn't a feature you bolt onto a finished data model. It's a decision about where the truth lives, and it has to be made before the schema is.
Build the reconciliation view before you ship the sync
Every offline system eventually produces a conflict, a duplicate, or a record that arrived out of order in a way your rules didn't anticipate. The teams that survive this build a reconciliation view early, something a support person or an ops lead can open to see exactly what synced, when, and what got auto-resolved versus flagged. This is the same discipline behind a safe admin console for support: give the humans who have to clean up an edge case the visibility to do it without touching the database directly.
If you're scoping this kind of system, the honest advice is to prototype the conflict cases before you write a line of sync code. Sit down with the two or three ways two devices could disagree, decide the rule for each, and only then design the outbox schema around it. Building the transport layer first and hoping the conflict rules fall out later is how you end up rewriting the sync protocol twice. If you want a second set of eyes on where the offline boundary should sit in your architecture, that's a conversation worth having early, not after the first data loss incident; our services page covers how we scope that kind of work, and contact us if you want to talk through your specific write patterns.
Frequently asked
What's the difference between offline-first and just caching data for offline reading?
Caching handles reads: you can view stale data with no connection. Offline-first handles writes: the user can create and modify data with no connection, and the system guarantees those writes are durable and eventually reconciled. Caching is much simpler and solves a different problem; most apps that claim to be offline-capable have only built the caching half.
How do I handle duplicate submissions when a device retries a failed sync?
Generate a unique idempotency key on the device at the moment of the write, before it ever touches the network, and enforce a uniqueness constraint on that key server-side. Retries then become safe no-ops instead of duplicate records, regardless of how many times the client resends the same payload.
Should conflict resolution happen on the client or the server?
Push it server-side whenever possible. The server sees every device's writes and can detect a real conflict; a single client only sees its own history and has to guess. Reserve client-side merge logic for cases where the server genuinely lacks the context to decide, which is rarer than it first appears.
Is offline-first worth the complexity for a typical SaaS product?
Only if your users actually operate somewhere connectivity fails regularly: retail floors, job sites, warehouses, vehicles. If your users sit at a desk with reliable wifi, the added complexity of local storage, sync protocols, and conflict rules outweighs the benefit. Build it when disconnection is a documented pattern, not a hypothetical.
How does offline-first affect database schema design?
It pushes you toward commutative operations (deltas instead of absolute values) so concurrent offline edits can merge safely, and it requires client-generated identifiers rather than server-assigned ones, since the client has to create durable records before it can reach the server to get an ID assigned.