← Back to homefrontend

Local-First and the Sync Engine Renaissance: Apps That Never Show a Spinner

← All writing

There is a category of app that feels different the moment you touch it. You press a key and the character appears. You drag a card and it lands. You close the laptop on a plane, keep working, and everything reconciles when you land. Linear feels like this; Figma feels like this. They aren't faster because their servers are closer — for most interactions, the server isn't in the loop at all.

That architecture has a name — local-first — and after years in research papers and weekend prototypes, it's now a legitimate default for real client work. The engines matured, the trade-offs got documented, and expectations ratcheted up until a 300ms round-trip per click reads as broken. "It's the network, nothing we can do" stopped being an acceptable answer; this post is about the architecture that replaces it.

Illustration: two notebooks in different rooms syncing through ribbons of light

What local-first actually means

The term comes from Ink & Switch's 2019 essay "Local-first software," still the best articulation of the idea: your data lives on your device, so software is fast; it works offline, because the network is optional rather than load-bearing; devices sync in the background, so collaboration still works; and — the part people forget — the user meaningfully owns their data, because the primary copy is theirs.

Strip away the ideology and the engineering claim is simple:

  1. The device holds a real copy of the working set — not a cache that might be stale or evicted, but a store the app treats as the source of truth for rendering.
  2. Reads and writes go to that local store, completing in microseconds to low milliseconds against memory or on-device SQLite/IndexedDB.
  3. Sync is a background process that pushes local changes up and pulls remote changes down whenever connectivity allows.
  4. Conflicts are a first-class design problem, not an error path — two devices will edit the same data while disconnected.

This is not bolted-on "offline mode": offline is the default execution path, and the network is an optimization for freshness and collaboration.

Why it's resurging now

The essay is seven years old. Why mainstream now? Three reasons, in our reading.

Linear and Figma reset the bar. Once a product manager has used Linear, there is no good answer to why your issue tracker takes half a second to open a ticket. Linear's sync engine — a client-side object graph persisted locally, mutations applied optimistically and reconciled through a server-ordered transaction log — became the reference architecture half the industry is now reverse-engineering. Instant stopped being a novelty and became a category expectation.

The engines matured. For years, local-first meant building your own sync protocol — distributed-systems work most product teams should not do. That's over. Rocicorp shipped Zero 1.0 in mid-2026 (its Replicache predecessor is now in maintenance mode), giving the server-authoritative model a stable, supported engine. Automerge 3.0 landed in late 2025 with a compressed runtime that cut memory for large documents by orders of magnitude — the project's own benchmark took a Moby-Dick-sized text from ~700MB to about 1.3MB — removing the biggest practical objection to CRDT documents. ElectricSQL rebuilt itself around a lean Postgres read-path model; PowerSync has quietly run production mobile fleets for years.

Wasm and browser storage got good. SQLite compiled to WebAssembly with OPFS persistence means the browser can host a real relational database — a credible local-first platform instead of a thin client with a lossy cache.

The architecture inversion

The conventional web app is a remote-first loop: render → user acts → HTTP request → server validates and writes → response → re-render. Every arrow in that chain is a place to show a spinner. Local-first replaces it with two independent loops:

  • The interaction loop runs entirely on-device: render from the local store → user acts → write to the local store → subscribers re-render. It never blocks on the network, so it never needs a spinner.
  • The sync loop runs in the background: push pending local mutations, pull remote changes, reconcile, update the local store — which re-renders the UI through the same subscription mechanism as a local edit.

Queries stop being requests and become subscriptions over local data: you don't fetch a list of issues, you subscribe to "open issues assigned to me," and the view updates whenever anything changes the result. isLoading mostly disappears from your component tree, deleting an entire class of state management.

The catch is the word "reconcile." Two loops means two writers, and two writers means conflicts.

The consistency problem

When a device and the server have diverged, something must decide the merged state. Two answers dominate, and choosing between them is the most consequential decision in a local-first design.

CRDTs: merge without a referee

Conflict-free Replicated Data Types are data structures mathematically guaranteed to converge: any replicas that have seen the same operations, in any order, arrive at the same state — no coordinator required. Yjs and Automerge are the two ecosystems that matter in practice.

The high-level trick: every element carries a stable identity and enough causal metadata (who wrote it, after what) that concurrent operations compose deterministically. Two people inserting text at the same position don't conflict — both insertions survive, ordered identically on every replica; concurrent writes to one key resolve by a tiebreak all replicas accept.

Working with Yjs looks like this:

import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { IndexeddbPersistence } from "y-indexeddb";

const doc = new Y.Doc();

// Boot offline with full data; sync in the background when connected.
new IndexeddbPersistence("project-42", doc);
new WebsocketProvider("wss://sync.example.com", "project-42", doc);

const tasks = doc.getMap<Y.Map<unknown>>("tasks");

// Writes are local and synchronous — no await, no spinner.
doc.transact(() => {
  const task = new Y.Map();
  task.set("title", "Ship the estimate");
  task.set("done", false);
  tasks.set(crypto.randomUUID(), task);
});

// Local and remote edits arrive through the same observer.
tasks.observeDeep(() => {
  render(tasks.toJSON());
});

No fetch, no mutation queue you manage, no reducer distinguishing "my change" from "their change" — that uniformity is the CRDT payoff, and it's why Yjs's editor bindings (ProseMirror, CodeMirror, Tiptap) power so many of the multiplayer editors you've used.

The costs are real, though:

  • Metadata and history. CRDTs retain tombstones and causal metadata, so documents grow with edit history, not just content. Automerge 3's compression attacks this hard and Yjs is lean, but a CRDT document is still heavier than its plain-JSON equivalent.
  • Convergent is not correct. CRDTs guarantee everyone sees the same state, not a valid one. Two offline users can each make an individually-legal edit whose merge violates a business rule — both claim the last seat. The math cannot know your invariants.
  • The server is a peer, not an authority. Rules that must hold — quotas, permissions, money — need enforcement elsewhere; the merge function will not provide it.

Server-authoritative sync with rebasing

The alternative — the Linear model, after the company that made it famous — keeps the server the single arbiter of truth while giving clients instant local writes. It works like git: clients apply mutations optimistically to their local store and queue them; the server applies incoming mutations in canonical order, enforcing whatever validation it likes; clients then rebase — discard optimistic results and reapply still-pending mutations atop the server's confirmed state.

Replicache pioneered the reusable form of this; Zero, its successor, carries it forward. The shape of the code:

// Client: mutators run instantly against the local store, then queue.
const mutators = {
  async updateIssueStatus(
    tx: WriteTransaction,
    { id, status }: { id: string; status: IssueStatus }
  ) {
    const issue = await tx.get<Issue>(`issue/${id}`);
    if (!issue) return;
    await tx.set(`issue/${id}`, { ...issue, status, updatedAt: Date.now() });
  },
};

// Server: the same mutation re-runs authoritatively, in order. It can
// enrich, clamp, or reject what the client did; the client's speculative
// version is discarded and rebased onto the server's answer.

The mutation is named intent ("update this issue's status"), not a state diff — which lets the server re-run it against current state and apply real business logic. If the server rejects it, the optimistic change vanishes on the next rebase and the UI snaps back.

The trade: you need a server, always, as the ordering authority — true peer-to-peer operation is off the table. In exchange you get centralized validation, real permissions, and conflict semantics your backend team already understands.

CRDTs (Yjs, Automerge)Server-authoritative + rebase (Zero, Replicache, Linear-style)
Merge decided byThe data structure, deterministicallyThe server, by re-running mutations in order
Server roleOptional relay/persistence peerRequired authority
Enforcing invariantsHard — convergence ≠ validityNatural — server validates every mutation
Offline durationIndefinite, even peer-to-peerLong, but reconciliation waits for the server
Sweet spotCollaborative documents, canvases, P2PBusiness apps: issues, CRMs, project tools

Our rule of thumb: a co-edited document → CRDT; records with business rules → server-authoritative. Plenty of real apps are both — embedding a Yjs document inside a record synced by something else is increasingly normal.

The engine landscape

An honest sketch of the options we'd actually shortlist:

  • Zero (Rocicorp). The most complete realization of the server-authoritative model: local SQLite, incremental query subscriptions via ZQL, server permissions, rebase-on-conflict, Postgres-backed. Hit 1.0 in 2026 — API stability matters. The trade is buying into its query language and running zero-cache.
  • ElectricSQL. Rebuilt from its original CRDT-heavy design into a deliberately narrow read-path sync engine for Postgres: it streams partial replicas ("shapes") into clients over HTTP and leaves the write path to you — your existing API, your validation. Less magic, easier to reason about.
  • PowerSync. Postgres (plus MongoDB and MySQL) synced to on-device SQLite; declarative sync rules decide who gets which rows, writes flow back through an upload endpoint you implement. Notably mature on mobile (Flutter, React Native, native SDKs). For a field app on flaky connectivity with existing Postgres, it's on the shortlist immediately.
  • Jazz. An opinionated, batteries-included framework built on collaborative values ("CoValues") with permissions and identity in the data layer itself, syncing via its cloud or self-hosted. Bigger buy-in, but it collapses auth, sync, and storage into one model instead of three integrations.
  • Automerge. The CRDT library closest to the Ink & Switch lineage: Rust core, automerge-repo for networking and storage plumbing. Version 3's memory work made large real-world documents practical. Choose it for document-shaped data and P2P-friendly designs.
  • Yjs. The workhorse — fast, small, a decade of production hardening, the richest editor-binding ecosystem. For multiplayer text or canvas, Yjs is the default and the burden of proof is on anything else.

The space is consolidating but not settled — isolate your engine choice behind a thin data layer.

What gets hard

Nobody sells you this part in the launch post — four problems dominate real projects.

Auth and permissions on synced data. A server-first app checks permissions per request. A sync engine replicates data ahead of any request, so authorization becomes a property of the replication stream: which rows may this user hold, not just view. Every serious engine has an answer — Zero's server-evaluated permissions, PowerSync's sync rules, Jazz's group-based access — but design row-level access early; retrofitting it is miserable. And anything synced to a device is readable by its owner, whatever the UI hides.

Partial sync. "Sync everything" works until a workspace has a million rows. Then you need the working set: which subset lives on this device, how the app behaves at its boundary, what happens when a subscribed query needs unsynced data. This quietly killed many first-generation attempts.

Schema migrations across offline clients. You cannot migrate every replica in one window, because some replicas are in a drawer. Old clients will wake up and push mutations written against a previous schema. You need versioned mutations, tolerant readers, and a policy for how stale a write you'll accept before forcing an upgrade. Treat your schema like a public API with old clients as consumers — that is literally what they are.

Server-side validation of client mutations. In the rebase model this is built in: the server re-runs every mutation and its result wins. Don't skimp — the client is untrusted, and "the app validated it" means nothing once someone speaks your sync protocol directly. In CRDT systems the merge already happened, so validation becomes after-the-fact repair: detect invariant violations post-merge and fix them with compensating edits. Design those flows deliberately.

Conflict UX: merge silently or surface it?

Most conflicts should be resolved silently — different fields of one record, concurrent list inserts, text edits in different paragraphs. "Conflict detected" dialogs for mechanically-resolvable overlaps are a design failure, and the era of Dropbox-style conflicted copy files should be dead.

But silence is wrong when intent conflicts, not just data: two offline edits to the same invoice amount, two people claiming one booking slot, an edit to a record someone else deleted. A deterministic winner exists, but the loser deserves to know. The pattern we like: resolve automatically, notify asymmetrically — never block the flow; pick the outcome, tell the affected user, keep the losing value reachable. A conflict resolved by silently discarding data is a bug report with a delay on it.

The highest-leverage move is upstream of UX, though: model data so conflicts can't occur — append-only events, per-field ownership, finer-grained records. The best conflict dialog is the one your schema made unnecessary.

Where it shines — and where it's wrong

Local-first earns its complexity in three situations: collaborative tools (multiplayer is nearly free once sync exists — presence and live cursors ride the same pipe); field and offline-hostile apps (inspections, logistics, clinics — anywhere "no signal" is Tuesday, a daily reality of building for Sri Lankan connectivity); and latency-sensitive tools people live in all day, where interaction speed is the product.

It is the wrong architecture when:

  • You need banking-grade consistency. Payments, inventory reservations, anything where two replicas independently "succeeding" creates real-world loss. These need serialized, online authority. A local-first app can still contain them — sync the ledger down for instant reads, but make the transfer an online transaction.
  • The dataset only makes sense server-side. Search over millions of rows, BI dashboards, admin panels over the whole customer base — there's no meaningful device working set to sync.
  • The data is too sensitive to replicate. Sometimes minimizing data-at-rest on endpoints is a hard compliance requirement, and it wins.
  • The app is genuinely simple. A brochure site, a form, a weekly-use CRUD admin — a sync engine is a distributed system you now operate. Don't buy one to avoid a spinner nobody minds.

A pragmatic adoption path

You don't rewrite onto a sync engine in one leap — with client work we never would. Three rungs, each independently valuable:

  1. Optimistic UI. Keep your server-first architecture; stop waiting on it. Apply mutations to client state immediately, send the request in the background, roll back on failure. TanStack Query's optimistic updates or Server Actions with useOptimistic gets you here in days. This alone kills most spinners — and teaches your team reconciliation, which is the actual skill.
  2. Cached, offline-tolerant reads. Persist the query cache (or a read replica of the working set) to IndexedDB/SQLite so the app boots instantly with data and survives a dead connection read-only. The network leaves the rendering path, and you're forced to answer staleness questions — valuable homework.
  3. A real sync engine. When the product demands live collaboration, true offline writes, or subscription-grade reactivity, adopt one — Electric or PowerSync to keep your write path, Zero for the full model, Yjs/Automerge for documents. By now your team understands optimistic state and rebasing — the migration is an upgrade, not a leap of faith.

Many products should stop at rung one or two — each rung is a shipped improvement, not scaffolding.

What it means for the "everything is a web service" default

For fifteen years the default was: the app is a thin projection of a server, and every meaningful operation is an HTTP call. That made sense when browsers couldn't store or compute much; it no longer describes the platform. Local-first is the correction: the server's irreplaceable jobs are durability, authority, and rendezvous between devices — interaction latency was never supposed to be one of them. We made users pay a network tax on every click because it was convenient for us; loading states, cache-invalidation folklore, and skeleton screens are the interest on that loan.

The pendulum won't swing all the way back; servers keep the truth, and should. But "open a fetch waterfall on every route" will look as dated as full-page form posts do now. Teams that internalize the two-loop model — interaction local, sync asynchronous — will ship software that simply feels better, and users won't know why. They'll just notice everything else feels slow.

Takeaways

  • Local-first inverts the loop: reads and writes hit an on-device store; sync runs in the background. Spinners disappear because the network leaves the interaction path.
  • It's mainstream now because the engines matured — Zero 1.0, Automerge 3, Electric's rebuilt sync, PowerSync's mobile track record — and Linear and Figma reset expectations.
  • Two consistency models: CRDTs (Yjs, Automerge) merge deterministically without an authority but can't enforce invariants; server-authoritative rebase (Zero, Linear-style) centralizes validation but requires a server as arbiter. Documents → CRDT; records → rebase.
  • The hard parts: replication-level permissions, partial sync, schema migrations with offline stragglers, server-side validation of client mutations. Design these first.
  • Resolve conflicts silently when only data overlaps; notify (never block) when intent collides — and prefer schemas that make conflicts impossible.
  • Great fit: collaboration, field/offline apps, latency-sensitive daily tools. Wrong fit: money-movement consistency, huge server-side datasets, sensitive data, simple CRUD.
  • Adopt in rungs — optimistic UI, then cached reads, then a full sync engine — and hide the engine behind a thin data layer, because this space is still consolidating.

Enjoyed the read? We build this stuff for clients too.

Start a project