Menu
Blog Documentation Community Pricing Demo Call Sign Up
Sign Up

Write Strategies for Replicache

Choose the right Replicache backend strategy — reset, global version, per-space, and row version with tradeoffs for each approach.

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 provides the client-side sync framework, but the backend integration is yours to build. The Replicache documentation describes four distinct backend strategies, each making different tradeoffs between simplicity, throughput, and flexibility. Choosing the right one depends on your data model, expected write volume, and whether you need fine-grained access control.

In this post, we’ll cover:

  • How mutations flow - Push, pull, poke, and the idempotency model
  • Reset strategy - The simplest approach for prototyping
  • Global version strategy - A single version counter for small apps
  • Per-space version strategy - Partitioned versioning for multi-tenant apps
  • Row version strategy - Fine-grained versioning for production scale
  • Choosing a strategy - Decision framework

How mutations flow

Before diving into the strategies, it’s worth understanding the common write-path mechanics that all four share.

When a user performs an action, the client calls a mutator—a named function that modifies the local Replicache key-value store. Replicache records the mutation (name, arguments, client ID, and a sequential mutation ID) and persists it locally. In the background, pending mutations are batched and sent to your server’s push endpoint via HTTP POST:

{
  "pushVersion": 1,
  "clientGroupID": "cg-abc123",
  "mutations": [
    {
      "clientID": "client-xyz",
      "id": 42,
      "name": "createTask",
      "args": { "id": "task-1", "title": "Buy groceries" }
    },
    {
      "clientID": "client-xyz",
      "id": 43,
      "name": "toggleTask",
      "args": { "id": "task-1" }
    }
  ]
}

Your push endpoint processes each mutation, executing server-side logic and writing to your database. After processing, the server sends a poke—a contentless notification via WebSocket, SSE, or polling—that tells clients to pull the latest state.

The pull endpoint returns a patch (the diff since the client’s last pull) and a cookie (a version marker). The client applies the patch, then rebases any unconfirmed local mutations on top of the new server state.

Idempotency: the must-advance rule

Each mutation carries a sequential ID per client. Your server tracks the lastMutationID for each client. The processing rules are strict:

  • If mutation.id <= lastMutationID: skip (already processed, likely a retry)
  • If mutation.id == lastMutationID + 1: execute and increment lastMutationID
  • If mutation.id > lastMutationID + 1: error (mutations arrived out of order)

The mutation’s effects and the lastMutationID update must be committed atomically—in the same database transaction. Without atomicity, you risk applying a mutation but not recording that it was applied, causing it to be replayed on the next push.

One critical rule: if a mutation fails permanently (validation error, authorization failure), the server must still increment lastMutationID. If it doesn’t, the client will re-send the mutation forever, creating a deadlock. Log the error, skip the mutation’s effects, but always advance the counter.

Strategy 1: Reset

The simplest possible backend. On every pull, the server sends the entire client view—a clear operation followed by put operations for every relevant row. No version tracking, no diffing.

// Pull endpoint (reset strategy)
app.post("/api/replicache/pull", async (req, res) => {
  const { clientGroupID } = req.body;

  // Fetch ALL data for this client group
  const todos = await db.query("SELECT * FROM todos WHERE user_id = $1", [userId]);

  res.json({
    cookie: Date.now(),
    lastMutationIDChanges: await getLastMutationIDs(clientGroupID),
    patch: [
      { op: "clear" },
      ...todos.map((todo) => ({
        op: "put",
        key: `todo/${todo.id}`,
        value: todo,
      })),
    ],
  });
});

The push endpoint is the same across all strategies—process mutations, update lastMutationID, commit atomically.

The reset strategy requires no extra columns on your domain tables. No version fields, no deleted flags. This makes it trivial to set up. The tradeoff is that pull performance scales with total data size, not change size. If a client has 10,000 rows and one row changed, the server still sends all 10,000 rows.

Use the reset strategy for prototyping, learning Replicache, or applications with very small datasets (hundreds of rows). Don’t use it for production workloads with meaningful data volumes.

Strategy 2: Global version

A single monotonically-increasing version number tracks all changes across the entire application. Every push increments the global version, and every modified row records the version at which it was last changed.

-- Schema additions
ALTER TABLE replicache_meta ADD COLUMN global_version BIGINT DEFAULT 0;
ALTER TABLE todos ADD COLUMN last_modified_version BIGINT DEFAULT 0;
ALTER TABLE todos ADD COLUMN deleted BOOLEAN DEFAULT FALSE;

The push endpoint reads the current version, computes nextVersion = version + 1, and sets last_modified_version = nextVersion on every modified row:

// Push endpoint (global version)
app.post("/api/replicache/push", async (req, res) => {
  await db.transaction(async (tx) => {
    const { global_version } = await tx.query(
      "SELECT global_version FROM replicache_meta FOR UPDATE"
    );
    const nextVersion = global_version + 1;

    for (const mutation of req.body.mutations) {
      const client = await getClient(tx, mutation.clientID);
      if (mutation.id <= client.lastMutationID) continue;

      // Execute mutation — set last_modified_version on affected rows
      switch (mutation.name) {
        case "createTask":
          await tx.query(
            `INSERT INTO todos (id, title, completed, last_modified_version)
             VALUES ($1, $2, false, $3)`,
            [mutation.args.id, mutation.args.title, nextVersion]
          );
          break;
        case "deleteTask":
          await tx.query(
            `UPDATE todos SET deleted = true, last_modified_version = $1
             WHERE id = $2`,
            [nextVersion, mutation.args.id]
          );
          break;
      }

      await updateLastMutationID(tx, mutation.clientID, mutation.id, nextVersion);
    }

    await tx.query(
      "UPDATE replicache_meta SET global_version = $1",
      [nextVersion]
    );
  });

  sendPoke();
  res.json({});
});

The pull endpoint queries only rows where last_modified_version > prevVersion:

// Pull endpoint (global version)
app.post("/api/replicache/pull", async (req, res) => {
  const prevVersion = req.body.cookie ?? 0;

  const { global_version } = await db.query(
    "SELECT global_version FROM replicache_meta"
  );
  const changed = await db.query(
    "SELECT * FROM todos WHERE last_modified_version > $1",
    [prevVersion]
  );
  const clients = await db.query(
    `SELECT id, last_mutation_id FROM replicache_clients
     WHERE client_group_id = $1 AND last_modified_version > $2`,
    [req.body.clientGroupID, prevVersion]
  );

  res.json({
    cookie: global_version,
    lastMutationIDChanges: Object.fromEntries(
      clients.map((c) => [c.id, c.last_mutation_id])
    ),
    patch: changed.map((row) =>
      row.deleted
        ? { op: "del", key: `todo/${row.id}` }
        : { op: "put", key: `todo/${row.id}`, value: row }
    ),
  });
});

The global version strategy is easy to implement and efficient for small-to-medium applications. The fundamental limitation is that the global version counter acts as a global lock—every push serializes on SELECT ... FOR UPDATE against the same row. This limits throughput to roughly 50 pushes per second (assuming ~20ms per push transaction). For a single-user app or small team collaboration tool, that’s plenty. For a multi-tenant SaaS serving thousands of concurrent users, it’s a bottleneck.

The strategy also requires soft deletes. You can’t actually delete rows because the pull endpoint needs to see them to generate del patches. Every query in your application must filter out deleted = true rows.

See what QueryPlane can build for you

Connect to your database, write SQL with AI, and build shareable apps — all from your browser.

Strategy 3: Per-space version

A natural extension of the global version strategy for multi-tenant applications. Instead of one global counter, each space (organization, workspace, room—whatever your natural partition is) has its own version counter.

CREATE TABLE replicache_spaces (
  id TEXT PRIMARY KEY,
  version BIGINT DEFAULT 0
);

Push and pull work identically to the global version strategy, but scoped to a specific space. The SELECT ... FOR UPDATE locks only the space’s version row, not a global row. This means pushes to different spaces are fully concurrent—you get ~50 pushes/second per space rather than ~50 globally.

// Push scoped to a space
const { version } = await tx.query(
  "SELECT version FROM replicache_spaces WHERE id = $1 FOR UPDATE",
  [spaceID]
);
const nextVersion = version + 1;
// ... process mutations with nextVersion ...
await tx.query(
  "UPDATE replicache_spaces SET version = $1 WHERE id = $2",
  [nextVersion, spaceID]
);

The tradeoff is that cross-space consistency is not guaranteed. If a mutation moves data from one space to another (e.g., transferring a task between organizations), the data may temporarily appear in both or neither space from a client’s perspective. If cross-partition operations are rare or can be handled with eventual consistency, this is acceptable.

Per-space versioning works well for applications with natural data partitions—project management tools partitioned by workspace, messaging apps partitioned by channel, multi-tenant SaaS partitioned by organization. If you’re running PostgreSQL with a multi-tenant schema, this maps cleanly to your existing partition boundaries. For more on schema design, see our post on choosing a primary key strategy for Postgres.

Strategy 4: Row version

The most capable and most complex strategy. Instead of a single version counter (global or per-space), each row has its own version that increments on every update. Pull endpoints compute diffs using Client View Records (CVRs)—snapshots of what data each client had at its last pull, stored in ephemeral storage like Redis.

The core idea: instead of asking “what changed since version N?”, you ask “what’s different between what this client had last time and what it should have now?”

// Pull endpoint (row version with CVR)
app.post("/api/replicache/pull", async (req, res) => {
  const prevCVR = await redis.get(`cvr:${req.body.cookie?.cvrID}`);

  // Compute current client view — can be ANY query
  const currentView = await db.query(
    `SELECT id, version FROM todos
     WHERE user_id = $1 AND NOT archived`,
    [userId]
  );

  const currentCVR = new Map(
    currentView.map((row) => [`todo/${row.id}`, row.version])
  );

  // Diff previous CVR against current CVR
  const puts = []; // rows to add or update
  const dels = []; // rows to remove

  for (const [key, version] of currentCVR) {
    if (!prevCVR?.has(key) || prevCVR.get(key) !== version) {
      puts.push(key);
    }
  }
  if (prevCVR) {
    for (const key of prevCVR.keys()) {
      if (!currentCVR.has(key)) {
        dels.push(key);
      }
    }
  }

  // Fetch full data only for changed rows
  const fullData = await db.query(
    "SELECT * FROM todos WHERE id = ANY($1)",
    [puts.map((k) => k.replace("todo/", ""))]
  );

  // Store new CVR
  const cvrID = crypto.randomUUID();
  await redis.set(`cvr:${cvrID}`, currentCVR, "EX", 86400);

  res.json({
    cookie: { cvrID, order: nextCVRVersion },
    lastMutationIDChanges: /* ... */,
    patch: [
      ...fullData.map((row) => ({ op: "put", key: `todo/${row.id}`, value: row })),
      ...dels.map((key) => ({ op: "del", key })),
    ],
  });
});

The row version strategy unlocks three capabilities the other strategies can’t provide:

No global lock. Push throughput scales with your database, not a single serialized counter. Performance is comparable to a traditional web application.

No soft deletes. Rows can be truly deleted from the database. The CVR diff detects removed rows (they appear in the previous CVR but not the current view) and generates del patches automatically.

Fine-grained read authorization. The client view query can be any arbitrary SQL query—including authorization checks, role-based filters, and computed fields. If a user’s permissions change (losing access to a row), the CVR diff detects the row’s disappearance from their view and sends a del patch. With global/per-space versioning, permission changes don’t bump the row’s version, so they’re invisible to the pull mechanism.

The cost is complexity. You need to maintain CVRs in a secondary store (Redis or similar), implement the two-step pull (first query IDs+versions to build the CVR, then fetch full data for changes), and handle CVR expiration and cleanup. The Replicache documentation provides a detailed implementation guide.

A useful PostgreSQL optimization: use the xmin system column as a natural row version. Every time a row is updated, Postgres assigns it a new transaction ID in xmin. This eliminates the need for an explicit version column, though xmin has caveats (it wraps around and isn’t stable across vacuum operations).

Choosing a strategy

ResetGlobal versionPer-space versionRow version
ComplexityTrivialEasyEasyHigh
ThroughputPoor~50 push/sec global~50 push/sec per spaceDatabase-level
Soft deletesNot neededRequiredRequiredNot needed
Read authorizationFull (filter query)HardSpace-level onlyFull (arbitrary query)
Partial syncFull (filter query)HardSpace-level onlyFull (arbitrary query)
Extra infrastructureNoneNoneNoneRedis (or similar)

Start with reset if you’re prototyping or learning Replicache. It’ll take 30 minutes to implement and lets you focus on the client-side experience.

Use global version for small applications with a single shared dataset—a team todo list, a small collaboration tool, or an internal dashboard. The ~50 push/sec limit is rarely a bottleneck for these use cases, and the implementation is straightforward.

Use per-space version for multi-tenant applications where data partitions naturally by workspace, organization, or channel. It gives you global-version simplicity within each space and eliminates the global lock. This is the sweet spot for many B2B SaaS applications.

Use row version for production applications that need high throughput, fine-grained access control, or partial sync. The implementation cost is higher, but it removes all the limitations of the other strategies. If you’re building something at the scale of a Linear or Superhuman, this is the strategy to use.

Note that Replicache is in maintenance modeRocicorp has shifted active development to Zero, which handles these backend concerns automatically. For new projects, evaluate Zero alongside Replicache.

Wrapping up

Replicache’s four backend strategies represent a clear progression from simple to production-grade. The reset strategy gets you running in minutes. Global version adds efficient incremental sync with minimal schema changes. Per-space version removes the global bottleneck for multi-tenant apps. Row version gives you full flexibility at the cost of implementing CVR-based diffing.

The common thread across all four is the push/pull/poke model and the idempotency contract: process mutations in order, always advance the mutation counter, commit atomically. Get that right, and switching between strategies is a matter of changing the pull endpoint.

For more on Replicache’s architecture, see our post on Replicache as a local-first sync framework. To see how other sync engines handle writes, check out our posts on write patterns for ElectricSQL and write patterns for PowerSync. For a comparison of all three tools, see ElectricSQL vs. PowerSync vs. Replicache.