durable-sync

A primitive, not a database

Offline-first sync for Cloudflare Durable Objects

An append-only op log on the server, a durable outbox on the client. No Postgres, no container, no WebSocket.

npm i durable-sync
View on npm ↗
  • Zero dependencies
  • 42 kB unpacked
  • TypeScript
  • MIT

The whole idea, running

Journal

Nothing acknowledged yet.

Outbox 0 queued

Empty. Every write has landed.

Go offline and keep writing — ops queue with no sequence number, because nothing has confirmed them. Come back online and the outbox drains: the journal assigns each op a seq, and only then is it safe to forget locally.

Why this exists

The platform already gives you the hard part

Every credible offline-first engine — Electric, PowerSync, InstantDB — wants Postgres and a long-running container. On an all-Cloudflare stack that is a second backend, forever. (Zero wants both too, and doesn't do offline writes at all — it says so itself.) Triplit had a Durable Object adapter, then its co-founder joined Supabase and it has sat unmaintained since. Meanwhile a Durable Object is a single-threaded ordering point, per user — which is the thing everyone else needs Postgres for.

  • Small on purpose

    It is a primitive, not a database. You bring the storage and the meaning.

  • No conflict resolution, deliberately

    Ops are immutable facts appended to a log. If your writes are events — "this happened" — you don't have a conflict problem. If you genuinely need merge, you want a CRDT.

  • HTTP, not WebSocket

    A socket is useless in a basement. Sync happens when the app is open and the network exists.

  • Zero dependencies

    Bring your own IndexedDB. The package ships two entry points and nothing else.

  • Snapshots, once the log is long

    The journal can't fold the log — it never reads a payload, which is what keeps it a primitive. So a client folds and the journal stores the result opaquely. Optional, and refusable: the log alone always rebuilds you.

Quickstart

Two halves, one afternoon

Server — the log

Extend the Durable Object and export it. One instance per user is the whole design: idFromName(userKey) buys serialized writes and natural isolation, for free.

// worker.ts
import { SyncJournal } from "durable-sync/server";
export class Journal extends SyncJournal {}
// wrangler.jsonc
{
  "durable_objects": {
    "bindings": [{ "name": "JOURNAL", "class_name": "Journal" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Journal"] }
  ]
}

Then forward POST and GET from your own route. Note what you do not route: DELETE (reset). The Durable Object serves it, but it stays reachable only through the binding — expose exactly what you want public.

Client — the outbox

Commit locally, queue the op, let the network catch up. apply must be idempotent: an op can arrive more than once.

import { createSync, localStorageCursor }
  from "durable-sync/client";

export const sync = createSync({
  endpoint: "/api/sync",
  outbox: dexieOutbox(db.outbox),
  cursor: localStorageCursor("myapp.cursor"),
  stateKey: "myapp.syncState",

  // Idempotent: an op can arrive twice.
  async apply(op) {
    if (op.kind !== "note") return false;
    const note = op.payload as Note;
    if (await db.notes.get(note.id)) return false;
    await db.notes.put(note);
    return true;
  },

  // Pushing is always safe. Pulling might not be.
  canPull: async () => !(await inProgress()),
});
// Writing is local-first.
await db.notes.put(note);
await sync.enqueue({ opId: note.id, kind: "note", payload: note });
void sync.now({ force: true }); // never await on a user path

The outbox must be durable. Until a push is acknowledged it may hold the only copy of a write, and memory does not survive iOS killing a backgrounded PWA. Put it in IndexedDB.

Four incidents

Every one of these shipped to production

These are the reason the package exists. Each is a real bug from the app this was extracted from — and each one looked completely fine from the outside, which is the point.

Silent data loss

A 200 that never reached the server

Behind an auth proxy like Cloudflare Access, an expired session redirects to a same-origin login page. fetch follows it and reports 200. Drain the outbox on res.ok and you delete writes that never landed.

What this does Validates the reply's shape before touching the queue. The outbox only drains against something the journal demonstrably wrote.

Silent stall

A cursor pointing into a log that no longer exists

Reset the log and every client still holds a sequence number from the old one — usually past the rebuilt one. So seq > cursor matches nothing and the client never syncs again, quietly, forever.

What this does Every pull carries an epoch. A client that sees a new one replays from zero. apply is idempotent, so replay is cheap.

Silent corruption

A pull that rewrote work in progress

Applying a remote op mid-workout overwrote the working weight that the finish logic reads back — and silently dropped a 5×5 to a 3×5. Pushing is always safe. Pulling is not.

What this does canPull() defers the pull while something is in flight. Eventual consistency makes waiting free.

Unrecoverable

A caller that reached past the gate

The journal is addressed by a server-side identity while ops carry whatever the client selected. Write as the wrong one and the data is filed under someone else — permanently, in an append-only log. The gate was on the engine; a caller went straight to the transport.

What this does The transport isn't exported. canWrite() can't be skipped, not even by force. There is one way in.

What this does not do

Being clear so you don't find out later

  • Conflict resolution. Last writer wins per record, and apply is yours.
  • Pagination. A pull returns everything after the cursor in one response. Fine for thousands of ops; not millions. Snapshots fold the cold start, but the tail is still one response.
  • Live push. No WebSocket, so the other device converges on its next foreground, not instantly.
  • Auth. Put it behind whatever you already use. The key you address the DO with is your isolation boundary.
  • Multi-tenancy per instance. createSync is single-tenant — one instance per identity, memoized.
  • Background sync. Safari has none. Nothing runs while the app is closed, and pretending otherwise would be a lie.

If you need any of those, you want a real sync engine — Zero, Electric, PowerSync — and the Postgres that comes with it. Here's an honest comparison ↗

Prior art

Worth reading first

partysync

Cloudflare's own, and the closest thing to this. Delta sync over a WebSocket, with an IndexedDB read cache — so it shows last-known state offline, it just has no write queue. Its README files that under a heading called "Maybe won't do." This does it.

Triplit

Had a Durable Object adapter. Unmaintained since September 2025 — not archived, but nothing merged or released since. Still worth reading.

Yjs

A real CRDT. Use it if you genuinely need merge.

Automerge

Also a real CRDT, with a different set of trade-offs.