How Replicache Local-First Sync Works
How Replicache's git-like sync model works — mutators, subscriptions, backend integration, offline support, and its successor Zero.
General
This post was written by an engineer at QueryPlane. QueryPlane is an app builder for your database: bring your own postgres db and you can create interactive applications to share with other developers, coworkers or even your customers. If you’re interested in trying it out, get started here.
Replicache is a JavaScript framework for building web applications that feel instant and work offline. It gives you a persistent, in-browser key-value store that syncs bidirectionally with your backend, using a git-like rebase model to merge local and remote changes. Your UI reads and writes to local data at memory speed, and synchronization happens in the background.
Replicache was created by Aaron Boodman and Erik Arvidsson at Rocicorp. It’s now open-source and free to use, though it’s in maintenance mode—Rocicorp has shifted active development to Zero, their next-generation sync engine.
In this post, we’ll cover:
- The sync model - How Replicache’s git-like rebase keeps local and server state consistent
- Mutators - Client-side and server-side mutation functions
- Subscriptions - Reactive queries that drive UI updates
- Backend integration - Push, pull, and poke endpoints
- Zero - The successor project and what it changes
- Tradeoffs - Where Replicache fits and its current status
The sync model: git-like rebase
Replicache’s core insight is that the sync problem is structurally similar to distributed version control. Each client maintains a local “branch” of state. Mutations are applied locally first (like local commits), then sent to the server. When the server pushes back canonical state, local mutations that haven’t been confirmed yet are replayed on top of the new base—exactly like a git rebase.
Here’s how it works step by step:
- The user performs an action. The app calls a mutator, which transactionally modifies the local Replicache store.
- Subscriptions fire immediately. Any standing query whose results changed triggers a callback, and the UI re-renders with the new local state.
- In the background, Replicache pushes the mutation to the server. The server executes its own version of the mutator against the canonical datastore.
- When the server state changes, it pokes connected clients (a lightweight notification, typically via WebSocket or Server-Sent Events).
- The client pulls the new canonical state from the server.
- Any unconfirmed local mutations are rebased on top of the new server state. The rebased result replaces the local state atomically.
The rebase step is what makes this work. If you increment a counter locally and someone else also increments it on the server, the rebase replays your increment on top of the server’s new value—you get the correct result without manual conflict resolution for most cases.
Mutators: dual implementation
Mutators are the functions that modify data in Replicache. They’re implemented twice: once on the client and once on the server.
The client-side mutator runs immediately when the user takes an action. It modifies the local key-value store and provides the instant feedback the user sees:
const rep = new Replicache({
mutators: {
async createTask(tx, { id, title, projectId }) {
await tx.set(`task/${id}`, {
id,
title,
projectId,
completed: false,
createdAt: Date.now(),
});
},
async toggleTask(tx, { id }) {
const task = await tx.get(`task/${id}`);
if (task) {
await tx.set(`task/${id}`, {
...task,
completed: !task.completed,
});
}
},
},
});
The server-side mutator runs when the mutation reaches the server via the push endpoint. It applies the same logical change to the canonical datastore—typically a SQL database like PostgreSQL. The server-side implementation is authoritative: its result always takes precedence over the client-side speculation.
// Server-side push handler
async function handlePush(req, res) {
const { mutations } = req.body;
for (const mutation of mutations) {
switch (mutation.name) {
case "createTask":
await db.query(
"INSERT INTO tasks (id, title, project_id, completed) VALUES ($1, $2, $3, false)",
[mutation.args.id, mutation.args.title, mutation.args.projectId]
);
break;
case "toggleTask":
await db.query(
"UPDATE tasks SET completed = NOT completed WHERE id = $1",
[mutation.args.id]
);
break;
}
}
}
An important detail: unique IDs should be passed into mutators as parameters, not generated inside them. Because mutators may be replayed during rebase, generating an ID inside the mutator would produce a different ID each time it runs.
The dual-mutator approach means the client-side version can be simple—it doesn’t need to handle authorization or complex business rules, since the server will enforce those. If your backend is also in JavaScript/TypeScript, you can share mutator logic between client and server to reduce duplication.
Subscriptions: reactive queries
Subscriptions are standing queries against the Replicache store. You provide a function that reads from the store, and Replicache automatically re-runs it whenever the underlying data changes—whether from a local mutation or a sync from the server.
const tasks = rep.subscribe(async (tx) => {
const allEntries = await tx
.scan({ prefix: "task/" })
.entries()
.toArray();
return allEntries
.map(([, value]) => value)
.filter((task) => task.projectId === currentProjectId)
.sort((a, b) => b.createdAt - a.createdAt);
});
In React, Replicache provides a useSubscribe hook that integrates with React’s rendering lifecycle:
function TaskList({ projectId }) {
const tasks = useSubscribe(rep, async (tx) => {
const entries = await tx.scan({ prefix: "task/" }).entries().toArray();
return entries
.map(([, v]) => v)
.filter((t) => t.projectId === projectId);
}, []);
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
Subscriptions are efficient—Replicache tracks which keys each subscription reads and only re-runs it when those specific keys change.
See what QueryPlane can build for you
Connect to your database, write SQL with AI, and build shareable apps — all from your browser.
Backend integration
Integrating Replicache with your backend requires three endpoints:
Push endpoint. Receives batches of mutations from the client. Your server processes each mutation, applies it to the database, and records the mutation ID so it can tell the client which mutations have been confirmed.
Pull endpoint. Returns the current server state (or a diff since the client’s last sync). The client uses this to update its local store and rebase any pending mutations.
Poke mechanism. A lightweight notification channel (WebSocket, SSE, or even polling) that tells the client “new data is available, do a pull.” The poke itself carries no data—it just triggers a pull.
Replicache is backend-agnostic. It works with PostgreSQL, MySQL, MongoDB, or any other datastore. The documentation provides integration guides for several backend patterns, including a detailed walkthrough for Postgres-backed implementations.
For teams running PostgreSQL, the push endpoint typically wraps mutations in a transaction. The pull endpoint queries the database with a cursor (often a global incrementing version number) to return only changes since the client’s last pull. This pairs well with PostgreSQL’s strong transaction guarantees—you get exactly-once mutation processing without building complex idempotency logic. For more on designing your Postgres schema for this kind of workload, see our posts on choosing a primary key strategy and UUID vs. integer IDs.
Zero: the successor
Rocicorp has shifted active development to Zero, a next-generation sync engine that builds on the lessons from Replicache. Replicache remains available, open-source, and free, but it’s in maintenance mode—no new features are being added.
Zero addresses several limitations of Replicache. Where Replicache syncs all data to the client upfront, Zero supports partial sync—data loads on demand as the application queries it. Where Replicache exposes a raw key-value store, Zero provides ZQL, a SQL-like query API with relations and reactive queries. And where Replicache leaves the server-side integration entirely to you, Zero includes a zero-cache server component that handles replication from Postgres, incremental view maintenance, and client fan-out.
Under the hood, Zero’s client library actually uses Replicache internally. It maintains a local SQLite database (via wa-sqlite) and materializes query results incrementally. The architecture is Postgres → zero-cache (server-side) → Replicache (client-side) → SQLite → your app.
Zero is currently in public alpha with PostgreSQL support. For new projects, it’s worth evaluating Zero alongside or instead of Replicache.
Tradeoffs
Replicache’s sync model is elegant and well-suited for highly interactive applications—the kind where every interaction needs to feel instant and multiple users might be editing shared state. The git-like rebase approach provides a principled way to handle concurrent edits without full CRDT complexity.
The main challenge is integration effort. Unlike managed sync services, Replicache requires you to build push, pull, and poke endpoints on your backend. You implement mutators twice (client and server). You manage the version tracking that powers incremental pull. For a simple CRUD app, this is a significant amount of infrastructure for what might be a modest UX improvement.
The key-value store model is another consideration. Replicache stores data as key → JSON value pairs. There’s no schema enforcement, no relational queries, no joins. If your application has complex data relationships, you end up doing a lot of in-memory filtering and joining in your subscription functions. This is one of the main motivations behind Zero’s SQL-based approach.
Replicache is also browser-only. It’s a JavaScript framework for web applications. If you need native mobile sync (Flutter, React Native, Swift, Kotlin), you’ll need a different solution like PowerSync.
Finally, the maintenance-mode status is worth weighing. Replicache is stable and battle-tested—it’s been used in production by thousands of developers—but if you’re starting a new project, depending on a framework that won’t receive new features carries risk. Zero is the intended path forward, but it’s still in alpha.
Wrapping up
Replicache pioneered a practical approach to local-first sync for web applications: a persistent client-side store with a git-like rebase model for keeping local and server state consistent. Its mutator pattern provides a clean separation between optimistic client-side updates and authoritative server-side logic, and its subscription system makes building reactive UIs straightforward.
For new projects, evaluate Zero as the evolution of these ideas with partial sync, a SQL-based query API, and built-in server components. For a comparison with other sync engines, see our post on ElectricSQL vs. PowerSync vs. Replicache. For more on the broader local-first ecosystem, check out our post on local-first databases.