Appearance
Local-first or local-later
A phone lost its connection after a user pressed Send. The message stayed on screen, so the user closed the app. When they reopened it, the message was gone.
The app had optimistic rendering and a client cache. It still was not local-first. Its pending write lived in memory, and the memory died with the page.
That failure is a useful test. "Works offline" can mean several different things:
- The static shell opens without a network.
- Old data can be read from a local replica.
- New work can be saved locally.
- Saved work survives a reload.
- The work reaches the server later.
- Conflicting work from several devices converges.
An app that meets only the first two conditions is local-later. It can display yesterday's state while waiting for the network to return. A local-first app lets the user continue and treats synchronization as a background concern.
This is the device-side counterpart to Measure the machine. Turn-level observability can prove that the server retried or failed, but only durable local state can prove that the user's action survived long enough to reach it.
Concept map
text
user action
|
v
local durable write -> local view -> later sync -> authoritative service
| ^ |
+---- reload -------+ +-> other clients
static shell cache -> starts the app
local replica -> supplies known data
write-ahead log -> preserves unsent intent
merge rule -> resolves concurrent changesThe pieces are related, but none substitutes for another. A service worker cannot preserve an in-memory mutation. A local replica cannot infer an action that was never recorded. A server cannot merge a write that the client lost.
Terms worth keeping straight
Local replica. A durable copy of synchronized records, usually in IndexedDB. It supports reads without a round trip.
Optimistic update. A UI change shown before the server accepts it. Optimism is a presentation choice, not a durability guarantee.
Pending write. A user action accepted locally but not yet confirmed by the authority.
Authority. The service that decides the committed shared state. Local-first does not require every replica to be equally authoritative.
Convergence. Replicas reach the same state after they exchange all accepted changes.
App shell. The HTML, JavaScript, CSS, fonts, and other files needed to start the app.
Decide what must work without the network
Do not begin with caching settings. Begin with user actions.
For each action, choose one of four policies:
- Local and durable. Save now, show now, synchronize later. Drafts, messages, checklist edits, and field updates often belong here.
- Local and temporary. Show now, but losing it on reload is acceptable. Hover state and an open menu fit.
- Online only. Disable the action and explain why. Payment capture, scarce inventory claims, and starting a remote computation may need a live decision.
- Server initiated. The user can view a cached result, but producing a new result requires the service. An assistant reply is a common example.
This classification keeps the offline promise honest. A composer can accept text offline even if the agent that answers it needs the network. A sandbox run may stay disabled because two tabs or devices must not both claim the same execution.
A write path with an explicit boundary
Keep the online and offline paths behind one function. Components should not decide whether a connection state is safe.
ts
type Write =
| {id: string; kind: "createNote"; note: {id: string; text: string}}
| {id: string; kind: "renameNote"; noteId: string; title: string};
async function submitWrite(write: Write) {
await localView.apply(write);
if (sync.status === "connected") {
try {
await sync.commit(write);
return;
} catch (error) {
if (!isNetworkError(error)) throw error;
}
}
await pendingWrites.append(write);
}The local view changes first. The durable pending log catches disconnected writes and network failures. Each write has a stable ID so a retry can be harmless.
Connection labels matter. "Connecting" is not the same as "connected." Some client libraries keep a connecting write in memory for a while, then discard it after a timeout or reload. Only use a state as a durable commit path when the library documents that guarantee.
Why one local database is not always enough
It is tempting to write pending actions directly into the synchronized replica's private storage. That couples application correctness to undocumented tables and upgrade behavior.
A small separate database is easier to reason about:
- The replica holds materialized shared state.
- The pending log holds user intent waiting for server acceptance.
- A pure overlay combines them for display.
This is duplication by role, not two competing sources of truth. Once the server confirms a write and the replica receives it, the pending entry disappears.
An app may use one IndexedDB database with separate object stores if the sync library supports that boundary. The important part is ownership. Application code must not mutate a sync engine's internal records.
Rejected shortcuts
Increase the disconnect timeout. This helps brief network changes. It does not survive a page process being killed.
Disable every write while offline. This is correct for actions that need immediate coordination. Applied to ordinary editing, it gives up the main benefit of local-first design.
Call an app local-first because it has IndexedDB. Storage technology says nothing about the write path, replay, conflicts, or UI truthfulness.
Make the browser authoritative. This can work for a single-device tool. It becomes fragile once links are shared or a server must enforce quotas and access.
Failure modes that show up in production
- The page displays a pending item, but the overlay does not restore it after reload.
- Two tabs flush the same pending entry and create duplicate side effects.
- A logout clears the cookie but leaves the local replica and drafts on a shared device.
- The UI shows a spinner for server work while the triggering action is still unsent.
- A delete remains queued behind an update to the same record and later resurrects stale work.
- Local timestamps run far into the future and win conflicts for days.
- The app shell opens offline, but a top-level network request crashes before cached data renders.
- The first creation of a shared space requires the network, although later edits are local-first.
These are contract failures, not cache misses. Test them as user flows.
Field checklist
Local-first begins when a user action is durably accepted on the device. The next problem is preserving and replaying those actions without duplication. That is the subject of The offline write-ahead log.