npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

durable-sync

v0.2.0

Published

Offline-first sync for Cloudflare Durable Objects: an append-only op log on the server, an outbox on the client.

Readme

durable-sync

Offline-first sync for Cloudflare Durable Objects. An append-only op log on the server, an outbox on the client. No Postgres, no container, no WebSocket.

npm i durable-sync

Docs and a live demo → · vs Zero, Electric, PowerSync

Cloudflare's own experimental partysync files offline support under a README heading called "Maybe won't do." This does it. That's the whole pitch.


Why this exists

If you want offline-first sync in 2026, the credible options — Electric, PowerSync, InstantDB — all want Postgres and a long-running container. On an all-Cloudflare stack that's a second backend, forever. (Zero wants both too, and doesn't do offline writes at all — "Zero is not local-first," in its own words.) Triplit was the one engine with a Durable Object adapter; its co-founder joined Supabase in October 2025 and it has sat unmaintained since — no commits since September 2025, docs site offline.

Meanwhile the platform already gives you the hard part for free: a Durable Object is a single-threaded ordering point, per user. That's what everyone else needs Postgres for.

So this is small on purpose:

  • Small on purpose. It is a primitive, not a database.
  • No conflict resolution, deliberately. Ops are immutable facts appended to a log. If two clients edit the same record and you need a merge, you want a CRDT — use Yjs or Automerge. If your writes are events ("this happened"), you don't have a conflict problem, and this is all you need.
  • 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.

What you get

| | | |---|---| | Server | append-only op log on DO SQLite; idempotent by opId; epochs | | Client | durable outbox; per-device cursor; idempotent apply; replay-on-epoch-change | | Lifecycle | sync on visibilitychange / pageshow / focus / online + poll, single-flighted and throttled | | Cold start | optional snapshots, so a new device doesn't replay a log years long | | Honesty | a status you can show users that cannot claim success it didn't have |

Server

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

One instance per user is the whole design — idFromName(userKey) gives you serialized writes and natural isolation, for free:

// app/api/sync/route.ts
export async function POST(req: Request) {
  const journal = journalFor(req);           // ns.get(ns.idFromName(userEmail))
  return journal.fetch("https://journal/push", {
    method: "POST",
    body: await req.text(),
    headers: { "content-type": "application/json" },
  });
}

export async function GET(req: Request) {
  const journal = journalFor(req);
  const q = new URL(req.url).searchParams;
  const since = q.get("since") ?? "0";
  // Forward `snapshot` too, or the client asks for a fold and never gets one —
  // which costs a slow cold start, not a wrong one.
  const snap = q.get("snapshot") === "1" ? "&snapshot=1" : "";
  return journal.fetch(`https://journal/pull?since=${encodeURIComponent(since)}${snap}`);
}

// Only if you're using snapshots.
export async function PUT(req: Request) {
  const journal = journalFor(req);
  return journal.fetch("https://journal/snapshot", {
    method: "PUT",
    body: await req.text(),
    headers: { "content-type": "application/json" },
  });
}

Note what you did not route: DELETE (reset). The DO serves it, but it's only reachable through the binding — export exactly what you want public.

Client

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

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

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

  // Pushing is always safe. Pulling might not be — see below.
  canPull: async () => !(await somethingInProgress()),
});

// Wire the lifecycle triggers. Call this from an effect, not at module scope —
// it returns a stop function, and there's nothing to listen to during SSR.
useEffect(() => sync.start(), []);

Writing is local-first: commit to IndexedDB, queue the op, let the network catch up.

await db.notes.put(note);
await sync.enqueue({ opId: note.id, kind: "note", payload: note });
void sync.now({ force: true });   // best-effort; never await this on a user path

force skips the throttle — a write that just happened shouldn't wait out a foregrounding window. It does not skip canWrite.

The outbox must be durable

Until a push is acknowledged, the outbox may hold the only copy of a write. Put it in IndexedDB — memory doesn't survive iOS killing a backgrounded PWA. With Dexie (outbox: "++id, opId"), that's:

const dexieOutbox = (table) => ({
  add: (op) => table.add(op),
  list: () => table.toArray(),
  remove: (ids) => table.bulkDelete(ids),
  has: async (opId) => (await table.where({ opId }).count()) > 0,
});

Snapshots, once the log is long

Skip this until you need it. A pull replays every op after your cursor, which is nothing at all for a year and a real wait after a few: a lifter three years in has tens of thousands of ops, and without a snapshot every new phone downloads all of them and runs apply on each before showing a single number. So does every client after a reset, since the recovery path is a replay from 0.

The journal can't fold the log for you — it never reads a payload, which is the whole reason it stays a primitive. So a client folds, and the journal stores the result as opaquely as it stores a payload:

export const sync = createSync({
  // ...as above
  snapshot: {
    capture: () => db.notes.toArray().then((notes) => ({ v: 1, notes })),
    async restore(blob) {
      // Refuse anything you don't recognise — a snapshot written by a build
      // that isn't this one. Returning false replays the whole log, which is
      // slow and always correct. Guessing is neither.
      if ((blob as any)?.v !== 1) return false;
      await db.notes.bulkPut((blob as any).notes);
      return true;
    },
  },
});

Nothing captures for you — only your app knows when its state is settled rather than mid-edit:

// after a sync, when the log has grown enough to be worth folding
void sync.capture();

The log is never pruned, so a snapshot is only ever an accelerator: it can be refused, corrupt, or absent and the log alone still rebuilds the client. Route PUT to use it (see below). The journal refuses a fold of a generation that no longer exists, so a reset can't be undone by a client that missed it.

Showing sync state

getState() / subscribe() are shaped for React's useSyncExternalStore; the snapshot is referentially stable between changes.

const state = useSyncExternalStore(sync.subscribe, sync.getState, sync.getServerState);
// { lastOkAt?: number, lastError?: string }

Use sync.getServerState — an inline () => ({}) returns a fresh object every call and React throws "The result of getServerSnapshot should be cached to avoid an infinite loop."

Ship this. Every failure path here is a silent catch — offline, a 500, an expired session — because none of them should interrupt the user. The cost is that "synced" and "silently broken for a week" look identical. This row is the only thing that tells them apart.

The four things this gets right that are easy to get wrong

These are the reason the package exists. Each one is a bug that shipped, in production, in the app this was extracted from.

1. res.ok is not evidence. Behind an auth proxy (Cloudflare Access and friends), an expired session redirects to a same-origin login page — which fetch follows and reports as 200. Drain the outbox on res.ok and you delete writes that never reached the server. This validates the reply's shape before touching the queue.

2. A cursor only means something against the log that issued it. Reset the log and every client holds a number from a log that no longer exists — usually pointing past the rebuilt one, so seq > cursor matches nothing and the client silently never syncs again. Every pull carries an epoch; a client that sees a new one replays from 0. apply is idempotent, so replay is cheap.

3. Pulling is not always safe. If applying a remote op can disturb work in progress, canPull() defers it. In the app this was extracted from, a pull mid-workout rewrote the working weight that the finish logic reads back — and silently dropped a 5×5 to 3×5. Pushing is always safe; pulling isn't.

4. The gate has to be the only door. canWrite() exists because the journal is addressed by a server-side identity while the ops carry whatever the client selected — so writing as the wrong one files data under someone else, permanently, in an append-only log. The same app had that gate on the engine and a caller that reached past it straight to the transport: valid ack, outbox drained, only copy gone. That's why the transport isn't exported here. There is one way in.

What this does not do

Being clear so you don't adopt it and find out:

  • No conflict resolution. Last writer wins per record, and apply is yours.
  • No pagination. pull returns everything after the cursor in one response. Fine for thousands of ops; not for millions.
  • No live push. No WebSocket, so the other device converges on its next foreground — not instantly. That's the trade for working in a basement.
  • No auth. Put it behind whatever you already use. The DO is addressed by whatever key you choose; that key is your isolation boundary.
  • One instance per identity. createSync is single-tenant: the in-flight guard and the throttle live in the closure. If your app has several identities on one device, create one instance per identity and memoize it — a fresh instance per render resets the throttle that exists to collapse the foregrounding burst.
  • Nothing runs while the app is closed. Safari has no Background Sync, 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.

Who uses it

Rampset — an offline-first barbell training log on Cloudflare Workers, Access and R2. It's where this was extracted from, and it's what the four warnings above are scar tissue from: a real workout was destroyed by the res.ok one, in production, before it was understood.

Rampset keeps what a sync library can't have — what its ops mean, and what applying one does to its database — and this package owns the transport.

Prior art

  • partysync — Cloudflare's own, and the closest thing to this: delta sync over a WebSocket with an IndexedDB read cache, so it survives a refresh offline. What it has no answer for is a write made while disconnected. Read it before you read this.
  • Triplit — had a DO adapter; unmaintained since Sept 2025. Worth reading.
  • Yjs / Automerge — real CRDTs. Use these if you genuinely need merge.

License

MIT