Skip to content

Build a small sync protocol

A shared list worked in tests and failed when published. Its first call returned a space-quota error. The runtime checker had created a new shared space on every page load, including several viewport and multiplayer checks. Test traffic had consumed the production quota.

The bug was not in document merging. Space creation, test isolation, quota accounting, and expiry are also part of synchronization.

A small sync service can cover forms, lists, polls, lightweight rooms, and many games. It still needs an explicit contract.

Protocol map

text
create space -> receive space id and capability keys
      |
      v
open local replica -> GET changes since cursor
      |                         |
      |                         +-> snapshot if cursor is stale
      v
local write -> durable outbox -> POST or WebSocket changes
                                  |
                                  v
                           accepted document
                                  |
                          append change log
                                  |
                          publish to room

REST handles creation, catch-up, and fallback. A WebSocket reduces latency and carries live changes and presence. Both paths apply the same authorization and merge rules.

Terms

Space. An isolated shared dataset within an application.

Collection. A named group of documents inside a space.

Cursor. An opaque position in the server's retained change log.

Snapshot. The current documents returned when incremental history is unavailable or no cursor exists.

Change log. A bounded sequence of accepted record identifiers or changes used for catch-up.

Fan-out. Delivery of an accepted write to connected clients.

Outbox. Durable client records waiting to upload.

Tombstone. A retained deletion marker that prevents an older live record from returning.

Start with four operations

A practical version needs four operations: create a space, get changes after a cursor, post changes, and open a WebSocket with a cursor and capability.

Space creation returns a random space ID, a read-write key, and a read-only key. The changes GET returns documents, a new cursor, and a reset flag. The changes POST returns accepted documents, rejected documents with reasons, and a cursor. The socket sends the same change shapes.

Use one wire document shape:

ts
type WireDoc = {
  collection: string;
  id: string;
  updatedAt: number;
  writer: string;
  deleted?: true;
  revision?: string;
  [field: string]: unknown;
};

type ChangeResponse = {
  docs: WireDoc[];
  cursor: string;
  reset: boolean;
};

The server assigns revision. Clients generate id, updatedAt, and writer under documented limits. Keep metadata names reserved.

Catch-up needs a reset path

Change logs cannot grow forever. Once a cursor predates retained history, the server must return a full snapshot with reset: true.

The client then replaces or reconciles its local materialized view with that snapshot, advances the cursor, and preserves newer pending writes in its outbox. A stale cursor is normal after a device sleeps for months. It should not require deleting the app or creating a new space.

Subscribe before reading the initial snapshot on the WebSocket server. Otherwise a write can land between "read current state" and "join live updates." The client misses it until another reconnect.

For a simple server:

  1. Authorize the space.
  2. Subscribe the connection to room fan-out.
  3. Read changes after the supplied cursor.
  4. Send a hello with the role.
  5. Send the changes or reset snapshot.

One accepted-write path

REST and WebSocket handlers should call the same function:

ts
async function applyBatch(ctx: Context, submitted: unknown[]) {
  const candidates = validateAndNormalize(submitted);

  const result = await store.commitBatch(ctx.space, async (tx) => {
    const current = await tx.loadCurrent(candidates);
    const usage = await tx.loadUsageForUpdate();
    const merged = mergeAndCheckQuotas(candidates, current, usage);

    for (const doc of merged.accepted) {
      const cursor = await tx.nextCursor();
      doc.revision = cursor;
      await tx.saveDocument(doc);
      await tx.appendLog(cursor, doc.collection, doc.id);
    }

    await tx.updateUsage(merged.usage);
    const fanoutId = await tx.enqueueFanout(merged.accepted);
    return {...merged, fanoutId};
  });

  const {fanoutId, ...response} = result;
  await store.publishCommitted(ctx.space, fanoutId);
  return response;
}

commitBatch atomically writes usage, documents, cursors, log entries, and the fan-out outbox. Merge and growth-quota checks use values read inside that transaction. Any failure rolls back the whole batch.

In Redis, a Lua script can check current documents and counters, then update them with the cursor, log, and outbox atomically. Keep a space's keys in one cluster hash slot. WATCH plus MULTI also works if callers retry conflicts and recompute. In SQL, lock the space or quota row with SELECT FOR UPDATE, then write every part in one transaction.

Publish only after commit. A pre-commit crash exposes nothing. A post-commit crash leaves durable log and outbox rows for a relay, while clients can catch up by cursor. The relay may publish twice, so clients must tolerate duplicates.

With several server instances, the store must serialize writes. A JavaScript mutex cannot do that.

Put quotas in the contract

Bound every dimension that can grow:

  • Spaces per app.
  • Documents and bytes per space.
  • Bytes per document.
  • Documents per request.
  • Writes per second.
  • Retained log entries.
  • WebSocket payload size.
  • Connected clients per room if needed.

Return stable error codes such as quota_docs, quota_bytes, and rate_limited. The SDK can then distinguish "retry later" from "this write cannot fit."

Test spaces need separate accounting. Mark spaces created from a fixed loopback check origin as ephemeral, exclude them from the normal counter, and give their metadata, documents, and logs a short expiry. Still rate-limit their creation. Otherwise validation becomes a denial-of-service tool.

Storage can stay ordinary

A key-value store can represent this protocol with:

  • One metadata hash per space.
  • One document hash per collection.
  • One bounded stream per space for cursors.
  • One pub/sub channel per space.
  • Quota counters per app and space.

The log need not contain full document bodies. It can record collection and ID, then catch-up reads the latest version. This compresses repeated edits to one returned document.

Persistence still matters. Enable append-only persistence or regular snapshots, back it up, and test restore. "Redis" does not mean "disposable" when it holds user data.

Alternatives rejected for the first version

WebSocket only. Corporate proxies, sleep, and mobile transitions break sockets. HTTP fallback makes recovery simpler.

Send the entire space on every reconnect. Fine for ten records, wasteful and slow once spaces grow.

Expose database sequence numbers directly. It couples clients to storage and complicates migrations. Treat cursors as opaque strings.

Per-app backend code. A schema-agnostic document service lets static apps share one hardened runtime.

Unlimited test spaces. Ephemeral does not mean unbounded.

Failure modes

  • A client presents a cursor newer than the current log after a restore.
  • The server trims the log but forgets to set reset.
  • The write is stored but not published, so live clients wait until reconnect.
  • The publish occurs before storage and clients read data that later disappears.
  • A quota counter commits separately from its documents and drifts after a crash.
  • A fan-out relay publishes an outbox row twice and the client applies it twice.
  • A WebSocket query key appears in proxy logs.
  • The same batch contains two versions of one document.
  • Tombstones expire before a long-offline client returns.
  • Quota counters never decrement when spaces are deleted.
  • Presence messages are persisted as documents and inflate quotas.

Field checklist

The protocol now moves versions reliably. It still needs a rule for choosing between concurrent versions. Chapter 15, Last-write-wins on purpose examines the simplest useful rule. The offline shell boundary appears in Chapter 13.

Built from field notes on durable software systems.