Skip to content
← All writing
February 18, 2026·7 min read

Local-First Apps: Building for Offline

Most apps are built cloud-first: the server holds the real data, and the app is a window onto it. It's the default architecture because it's the easiest one to reason about — one source of truth, in a place you control. But it means the app inherits the network's moods: lose connectivity and the window fogs up; every tap waits on a round trip; a backend outage becomes everyone's outage. Local-first — the term comes from Ink & Switch's influential essay, which is still the best long read on the subject — flips that model: the device holds the data, the app works instantly whether or not there's a network, and syncing to the cloud happens quietly in the background.

I build my own apps this way where the shape of the data allows it — an expense tracker that needs a signal to record a coffee would be a bad expense tracker — so this post is written from the practitioner's side: the ideas, the payoff, and an honest account of the part that's genuinely hard.

The core idea

In a local-first app, the local device is the primary source of truth. Every read and write hits local storage first — SQLite, typically, for structured data — so the interface responds immediately, always. Changes are then synced to a server (and onward to the user's other devices) whenever a connection is available. The network becomes an enhancement — backup, sharing, multi-device — rather than a requirement for the app to function at all.

Contrast the cloud-first default, where a tap waits on a server round trip, a slow connection means a janky experience, and a dropped one means a spinner or an error state. Local-first inverts the dependency: the app is whole on its own, and the cloud serves it.

Why it's worth the effort

  • Instant interactions. Reading and writing local data has effectively zero latency, so the app feels immediate — not "fast for a network app," but immediate, every tap, every time. Users can't articulate why the app feels good; this is why.
  • True offline support. Subways, planes, dead zones, roaming abroad with data off, rural coverage — the app just works and syncs later. For a utility someone reaches for many times a day, "sometimes doesn't work" quietly becomes "stopped opening it."
  • Resilience. Your backend deploy, your host's outage, an expired certificate — none of it takes the user's experience down with it. The blast radius of server trouble shrinks to "sync is delayed."
  • Often better privacy. Data can live primarily on the device, syncing only what's needed, encrypted in transit. "Your data stays on your phone unless you enable backup" is an architecture and a marketing sentence.

How syncing works

The heart of a local-first app is the sync engine. The usual pattern:

  1. Write locally first. The change commits to the device database and the UI updates immediately.
  2. Queue the change. It's appended to a durable list of pending changes — durable meaning it survives the app being killed before sync happens.
  3. Sync when possible. When connectivity allows, pending changes push to the server and remote changes (from other devices or a restored backup) pull down.
  4. Merge. Local and remote states are reconciled into one consistent result.

Steps 1–3 are mechanical — careful engineering, but well-trodden. Step 4 is where the difficulty lives, and it deserves its own section because it's the step that determines whether users ever lose work.

The hard part: conflicts

When the same data can be edited on multiple devices while offline, versions diverge: the phone renamed the budget while the tablet, offline on a plane, changed its amount. Deciding what the "correct" merged result is — without losing anyone's work, without asking the user to think about distributed systems — is the central design problem of local-first. The common strategies, in ascending order of effort:

  • Last-write-wins. The most recent change (by timestamp) overwrites the others. Trivial to implement, and it silently discards edits — the tablet's amount change evaporates because the phone's rename came later. Acceptable for low-stakes, single-user data; corrosive anywhere users would notice the loss.
  • Field-level merging. Track changes per field, not per record: the rename and the amount change touch different fields, so both survive automatically, and only concurrent edits to the same field count as real conflicts. This is dramatically friendlier in practice, converts most would-be conflicts into clean merges, and is usually the right effort level for a typical app.
  • CRDTs (Conflict-free Replicated Data Types). Data structures mathematically designed so concurrent edits always merge to the same consistent result on every device, no coordination needed. They're what collaborative text editors are built on, and libraries like Automerge and Yjs have made them practical — but they constrain how you model data and add real conceptual weight. Reach for them when concurrent editing is the product (shared documents, collaboration), not for a personal tracker.
  • Ask the user. For genuinely irreconcilable cases, surface both versions and let the person choose. If your field-level merging is good this is rare — but as an escape hatch it beats guessing, and for high-stakes data it's the only honest answer.

Choose based on the stakes of the data, not engineering aesthetics: a draft note tolerates last-write-wins; a shared financial ledger cannot. And whatever you choose, test it — two simulated devices, both offline, conflicting edits, then sync. It's an integration test worth its weight in support tickets.

Practical guidance

  • Give records device-generated stable IDs — UUIDs, not sequential server IDs. Two devices creating "expense #17" offline is a collision by design; two devices creating UUIDs never collide, and IDs never need rewriting during sync (which would otherwise cascade through every reference).
  • Track sync state per change. Pending, synced, failed — visible in your data model, retryable with backoff. Silent sync failure is data loss on a delay timer.
  • Design the data for merging. Many small, independent fields merge cleanly; one big JSON blob is a guaranteed conflict every time two devices touch it. The schema is part of the sync strategy.
  • Show sync status honestly but calmly. A subtle "synced / syncing / offline — changes saved" indicator builds trust without turning the UI into a network monitor. Users mainly need to know one thing: nothing is lost.
  • Consider a framework before building. Sync engines are deceptively deep — auth, deltas, retries, migrations of unsynced data across app versions. Depending on your stack, options range from Firestore's offline persistence to purpose-built sync layers like PowerSync or ElectricSQL, or CRDT libraries if collaboration is the point. Building your own is a fine choice only when made knowingly.

Is it always worth it?

No. Local-first adds real complexity — the sync engine, the conflict strategy, the testing burden — and not every app earns it back. If your app is inherently online (a live feed, real-time multiplayer, anything where stale data is worse than no data), cloud-first is the honest fit, and offline support reduces sensibly to caching-for-reading. The payoff concentrates in apps people use frequently, personally, and in the real world's patchy coverage: notes, trackers, budgets, field tools, anything with "capture" at its core. A middle path — reads cached offline, writes queued for a few key flows — captures much of the value for a fraction of the cost, and is a perfectly respectable place to stop.

Summary

Local-first architecture makes the device the source of truth, so apps feel instant and keep working offline, syncing in the background when they can. The benefits — immediacy, true offline support, resilience to your own backend's bad days — are substantial and user-perceptible; the price is a sync engine and, above all, a deliberate conflict strategy. Use device-generated IDs, track sync state durably, shape the schema for field-level merging, lean on a sync framework where one fits, and match the conflict strategy to the stakes of the data. For apps used often in the real, unreliable world, it's one of the few architecture choices users can feel — every single time they open the app.

Further reading