Most web apps treat the server as the source of truth: every action sends a request, waits for the reply, then updates the UI. Lose the network and the app freezes. For Financial Plan (a monthly budgeting PWA), I went the other way: local-first. Data lives on the device, and the app works offline and even when the server is down. The server is only there to sync between devices and keep a backup.
Making an app work offline isn't the hard part. The hard part is syncing when the same person edits on a laptop and a phone, sometimes online and sometimes not. This post covers the main decisions and the edge cases I ran into.
Architecture
- The UI only reads and writes IndexedDB (through Dexie) and never waits on the network. Every record has an
updatedAtand adirtyflag meaning "not on the server yet". - A service worker caches the whole app (every route, the fonts), so it opens offline. A new version only takes over when the user taps "Reload", so the UI never changes on its own.
- The app calls
navigator.storage.persist()so the browser doesn't evict its data when the device runs low on space. Without it, IndexedDB is only "best-effort" storage. - The data schema is written in zod in a shared package. Client and server validate with the same schema.
The sync protocol: one request, both directions
Each sync is a single request: push the dirty records and, in the same round trip, pull everything changed since the last cursor. The server gives every change an increasing seq number, and the next pull only fetches what has a seq greater than the cursor.
Sync runs in the background:
- 1.5 seconds after each change (so a burst of typing becomes one request),
- when the network comes back, and when the user returns to the tab,
- and once a minute.
On failure (server down, timeout, 5xx, 429), the app retries with exponential backoff from 5 seconds up to 5 minutes. The changes stay safely queued on the device.
Multiple tabs are an easy case to forget: two tabs syncing at once push duplicates and fight over the cursor. The Web Locks API solves it neatly: whichever tab gets the lock syncs, the others skip. In simplified form:
await navigator.locks.request("finance-sync", { ifAvailable: true }, async (lock) => {
if (!lock) return // another tab is syncing
await syncOnce()
})
Conflicts: last writer wins, per record
Conflict resolution ranges from simple (last-writer-wins) to complex (CRDTs). For personal finance data, where two devices rarely edit the same field at the same moment, last-writer-wins per record is enough, as long as a few details are right:
- The server only overwrites when
updatedAtis newer. That makes re-sending a change harmless (idempotent). It matters because a request can succeed but lose its response, and the client will send it again. - Pushing a version older than the server's gets the current version sent back so the client can catch up.
- Deletes are tombstones (
data: null), not real deletes. With a real delete, other devices have no way to learn the record is gone, and would push it right back.
seq order must match commit order
This one is subtle. Say two syncs for the same user run concurrently: request A takes seq = 10 but commits slowly, request B takes seq = 11 and commits first. A pull in between sees seq = 11, moves its cursor to 11, and misses change 10 forever.
The fix: only one sync per user at a time, using a PostgreSQL advisory lock inside the transaction:
SELECT pg_try_advisory_xact_lock(hashtextextended($userId, 0))
Note the try. If a sync is already running, the new request gets an immediate 429 and the client retries, instead of waiting. Waiting requests hold the pool's connections, and every other user queues up behind them.
Clocks: don't trust each device's time
Last-writer-wins relies on updatedAt, and updatedAt comes from the device's clock. A phone running 10 minutes fast "wins" every edit made elsewhere during those 10 minutes.
Two layers of protection:
Correct to the server's clock, NTP-style. Every sync response includes
serverTime. The client computes its offset against the middle of the round trip and stamps changes with the corrected time:const clockOffset = (serverTime: number, sentAt: number, receivedAt: number) => Math.round(serverTime - (sentAt + receivedAt) / 2)The server never stores a time more than 1 minute ahead of its own. Anything stamped further ahead is pulled back to that limit, so a device with a wrong clock can't keep winning.
One more small but important detail: saving a record unchanged doesn't count as a change. Many forms save every field they display. If each "Save" stamped a new time, a form on the laptop would silently overwrite an edit just made on the phone.
Design the data to avoid conflicts
The best conflict resolution is not having the conflict. Originally a month was one record, holding both the plan and the actual figures. Editing the plan on a laptop while entering actuals on a phone meant one side lost.
The fix was to split a month into three records: the plan, the actuals when the month is closed, and the actuals being entered. These are usually edited at different times on different devices, so once split, conflicts all but disappear. Old single-record months are still readable and get split once the device has synced.
The general rule: the unit of sync should match the unit a user edits at one time.
Other edge cases
- An old app meets new data. A user may open an old (cached) version of the app while another device has synced a new kind of record. Records of an unknown kind are kept untouched on the device, don't break the sync, and get used once the app updates.
- Signing in is optional. If the auth server is down, the app keeps working. A token that can't be refreshed because the network is gone only pauses syncing; it doesn't sign the user out.
- Data created before signing in. If the account is empty, the data goes up automatically. If the account already has data, the app asks: use the account's data, or merge this device's in. Never decide that for the user.
- A newly signed-in device waits for its first sync before showing any figures. Otherwise it could create an empty plan and push it over the real data.
- Signing out clears local data (the device may be shared), but only after pushing pending changes. If some can't be sent, the app warns first.
- Knowing who changed what. Every record a device pushes is written to an audit log whose own history can't be quietly rewritten. How that works is in a tamper-evident audit log in PostgreSQL.
When local-first is worth it
Local-first pays off when users need the app to be fast and work offline, and the data mostly belongs to one person using a few devices. It fits less well when many people edit the same data in real time (look at CRDTs or a server as arbiter then), or when the data is too large to keep on the device.
Summary
- The UI only reads and writes IndexedDB and never waits on the network; the server syncs between devices and keeps a backup.
- One request pushes dirty records and pulls everything after the cursor, with backoff on failure and a Web Lock so only one tab syncs.
- Last-writer-wins per record is enough for one person's data, if overwrites are idempotent and deletes are tombstones.
- A
seqhanded out before commit can become visible after a higher one, so allow one sync per user at a time with a non-blocking advisory lock. - Don't trust device clocks: correct them to the server's time, cap timestamps on the server, and don't count an unchanged save as a change.
- Shape the data so the unit of sync matches the unit a user edits at one time; splitting a month into three records removed most conflicts.
To go deeper, Ink & Switch's essay Local-first software is a great place to start. And if you're building an offline app too and want to compare notes, my email is on the home page.