React + Durable Object sync, via TanStack DB
28th June 2026
tl;dr: tanstack-durable-object-sync syncs your Durable Object sqlite state over a single WebSocket per client. We use TanStack DB to provide optimistic mutations and live queries. It’s built for LLM-native workflows and CRUD apps alike.
This post is to explain some of the journey: lots of googling for “durable object react sync” and frustrations around all the existing great sync solutions requiring external Postgres services. Our prototype was partykit and Yjs and god did that have a host of its own terrible challenges including being non-existent typing and authorization, inexcusable drawbacks in 2026. Surely we can figure out a better way.
We just wanted to sync some SQLite
Deploying Durable Objects is life on easy mode – a little server running close to the user, with a single, authoritative, strongly-consistent little database living inside of it. The DB is local so reads and writes take microseconds. The challenge becomes last mile: you can write simple frontend-backends (probably using Hono and REST APIs) in your Worker code, then pull Durable Object data over RPC, and load your little CRUD pages and do your little mutations. But levelling up from here gets a whole lot harder.
What we actually needed was specific:
- Lightweight enough to scale. Many tables and collections on a single DO, without per-collection overhead. Good structure should be free.
- Built for LLM-style change streams. Token-by-token streaming should be performant by design and minimal overhead. Built for modern user interfaces no matter the update source.
- A real authorization story. Clean filtering for synced read collections, solid mutation authorization. Audit logs are as easy as INSERTing when you UPDATE.
- Authentication-independent. We didn’t want to prescribe auth strategies. Bring your own
Claimsfor DB sync clients.
Where we started: Yjs and partykit
The first version used Yjs on partykit. It got us moving, but it hurt us in a few ways that did not get better over time.
- Yjs is inherently untyped and unstructured. By nature, any client can write any object or shape to anything and Yjs will sync it. There’s way too much trust in any old client. On top, Yjs itself has no real typescript support even for “design-time” typing.
- CRDTs are a poor fit for server-authoritative CRUD. CRDTs are amazing for P2P and distributed apps, and Yjs has been the front-runner for syncing applications that aren’t just text for years. But if your app is traditional with some models/tables with shape and CRUD-like actions, you just don’t need the commutative (and other) guarantees of CRDTs. Just read and write the server.
- Poor authz story + Anyone can write anything. This was the dealbreaker. Yjs leaves authorization up to you. See the community’s own long threads on how to deal with user authorization and validation, security, and middleware. The model assumes every client is allowed to change the shared document, so you have to add your own checks back on top. You’re probably not building Figma.
Also, at time of writing, the HTTPS certificate for discuss.yjs.dev has been expired for over a month. Death by a thousand cuts, which I’d rather not be relying on for a production app.
Why TanStack DB
Meanwhile, TanStack DB enters the scene and matures to beta. Like everything that comes out of the Tanstack org (and Tanner Linsley), it’s good. It provides the client-side layer and a lot of features we’d have to build ourselves: typed collections, optimistic updates, and live queries. We get a great reactive, client-first store for free. It also includes a really nice sync story, battle tested on the excellent Electric backend among others.
And why not…
- But Electric requires a big ol’ Postgres database, even though it can stream Postgres changes to a Durable Object sqlite as its cache.
- Zero is like Electric, a bit nicer for authz-filtered CRUD apps, but still requires Postgres. Doesn’t run on Durable Objects.
- LiveStore supports syncing against Durable Object sqlite, but is wholly event-stream based. Streaming LLM outputs token-by-token blows out the event log forever. Events (not data) are stored at rest, so it does not support plain SQL statements, ORMs, etc.
So we made a bet on Tanstack DB as the base, take inspiration from the elastic sync backend for it, simplifying things for the single-writer SQLite case. A single changelog across sync-enabled tables. Changes sync one-way from server to client. Mutations plug cleanly into Tanstack DB and have a simple authorize-execute-afterCommit API.
Under the hood
With a single writer, the living is easy. DOs are single-threaded, easy to access over HTTP, RPC, or WebSocket. We add mutations. When the DO boots, we configure TRIGGERs on the tables for observation. Now you can run SQL (update, insert, delete, whatever) and it appends to the CDC log, sending changes to clients. CDC sequence is shared across all tables, so the changelog can be streamed (and caught up) over a single stream.
- One ordered stream, one cursor. Every change in the DO gets number that only goes up, its sequence number. There is one sequence and changelog for the whole Durable Object. Server uses this number for tracking what needs to be sent to each client, and clients also use this sequence number for efficient reconnection catch-up. Since it’s one changelog, clients only need one Websocket connection per DO, even when syncing lots of tables/collections. The DO hibernates when there’s no real activity, even if clients stay connected; and connections can sleep when idle with efficient wake-up on tab focus.
- What the log stores. A bit different from purely event-based solutions (which essentially store the mutation itself), or state-based CRDTs which store row contents, our log is essentially pointers. Each entry holds which table changed, the Primary Key of the affected row, the operation (insert, update, delete), and the time. When the server sends a change to the client, it reads and sends the current row from the table. So the log stays small, and the client gets only the latest version of the affected row data, without any stale or redundant records.
- Confirming mutation writes. A mutation’s
executefunction runs wrapped in a single SQLite transaction. The table trigger runs and records each change into the change log in that same transaction, so your mutation and its log entry commit together or not at all. The confirmation rides the write’s own id, not a row coming back — so even a write whose row matches nothing you’re currently watching still confirms, the same way. - Tables stay your source of truth, so create and migrate them however you prefer: raw SQL, Drizzle ORM, Kysely, or a versioned migrator like
SQLSchemaMigrationsfrom@cloudflare/actors/storage. And when the server itself writes rows outside a mutation — a webhook, a cron job, an agent mid-stream — wrap the write inthis.runSyncedWriteso it’s broadcast to clients instead of sitting silently in the log. - Batching changes sent to clients. Whether changes come from a mutation or from other application code (RPC calls, webhooks, LLM streams, whatever), they’re written to the changelog. But blasting the client with every update is a cost not just to us, but also to our users, so we want to balance fast feedback with filling their connections. So by default, changes are batched per 50 millisecond window and only the latest values are sent – a hundred updates to a row within the window coalesce to a single update to the client.
- Keys come from the client. You create the row’s id in the browser with a ULID or UUID before you send it. The optimistic row and the server-confirmed row share the same id, the new row comes through the same sync process as all other updates, and the optimistic row is seamlessly slotted in-place. You’re allowed to forgo this, you’ll just lose efficient re-rendering of optimistic mutations.
- Mutations and authorization. On the server you list the mutations each collection allows, and each one has three parts. The
authorizestep runs first, before any write, with the connected user’sClaims(passed from the worker during WS connection), and youthrowto deny. Theexecutestep runs inside the transaction and makes the change. An optionalafterCommitstep runs after the write is confirmed, for effects outside the database, e.g., sending a notification, triggering LLM turns, or other side-effects. Updating multiple tables in a single mutation (such as adding audit logs) is simply running more updates or inserts insideexecute. - Mutations for optimistic CRUD; commands are RPC. A mutation is a standard CRUD operation on a collection, and Tanstack DB uses this to optimistically render the state while it syncs. For anything else, you reach for a command (
sync.command) instead. A command can do any async work, including callsql.execor side effects unrelated to the database, owns its own atomicity, and it has no optimistic overlay — the client calls it async, and gets the return value once execution resolves. - Reads and filtering. A subscription is a collection and a filter. Server-side, the client-configured predicate is combined with what the client is allowed to read, so each subscription only gets sent the subset of what it’s allowed to read (e.g.
deleted_at IS NULL), plus what it’s asked for (e.g.status IN ('todo', 'in_progress')). Filtering supports pagination, cursor-based queries, and live queries. The server sends as little as possible, then the client re-filters where necessary. - Retention, compaction, and reconnect. Since the changelog itself is lightweight and actual data remains stored in its respective tables, we can easily and efficiently compact redundant logs. If a single
messagesrow updates 100 times, new and existing clients only need to know the latest time (or sequence) it was updated and the current value – we can remove the previous 99 update logs for that row. Additionally, we can assume clients won’t need efficient catch-up reconnection after (by default) 2 days – these clients will treat catch-up as fresh re-connection with the same result, and server side we can remove any logs older than that. This process happens infrequently and is very quick. The “sequence zero” source of truth remains your tables, and any table with a primary key is compatible.
On the client, it is plain TanStack DB. Here is an optimistic insert and a live query.
// Optimistic insert: shows instantly, confirmed by the DO on the same stream.
messages.insert({ id: ulid(), author: me, content, created_at: Date.now() })
// A live query that re-renders as confirmed changes (yours and everyone's) arrive.
const { data } = useLiveQuery((q) =>
q.from({ m: messages }).orderBy(({ m }) => m.created_at, "asc"),
)
You authenticate at your Worker and hand the Durable Object a Claims-shaped object, which can be any shape you like. In our case, we pass { userId, organizationId, role }, which is enough to authorize any mutation in its authorize method.
Give it a spin
There’s a number of runnable examples in the repo: chat (the minimal end-to-end shape), on-demand (each panel lazily syncs only its subset), and a task board — the at-scale stress test, 5,000 tasks on one DO with windowed pagination and cursor scroll-back. It’s fully open source: grrowl/tanstack-durable-object-sync.
You don’t need a full example, it’s so simple. Check out the client:
import { createCollection } from "@tanstack/db"
import { useLiveQuery } from "@tanstack/react-db"
import { ulid } from "ulid"
import { doCollectionOptions, WebSocketTransport } from "tanstack-durable-object-sync/client"
import type { Api } from "./session-do" // type-only: nothing server-side is bundled
const transport = new WebSocketTransport<Api>({
url: `wss://${location.host}/sync?room=global`,
})
// One transport per DO, shared by every collection on it.
// The row type is inferred from the Api + table name — no explicit Row generic.
const messages = createCollection(
doCollectionOptions<Api, "messages">({ transport, table: "messages", getKey: (m) => m.id }),
)
// Commands run over the transport, not the collection — fully typed by the Api.
// `call` is a Proxy: the name autocompletes and the result is checked.
const clearRoom = () => transport.call.clearRoom() // Promise<{ deleted: number }>
// then in your components:
function MessagesList({ me }: { me: string }) {
const { data } = useLiveQuery((q) => q.from({ m: messages }).orderBy(({ m }) => m.created_at, "asc"))
return (
<ul>
{data.map((m) => (
<li key={m.id} style={{ padding: "2px 0" }}>
<b style={{ color: m.author === me ? "#0a7" : "#357" }}>{m.author}</b>: {m.content}
</li>
))}
</ul>
)
}
and in the Durable Object:
import { defineSync, SyncDurableObject } from "tanstack-durable-object-sync"
// Bind your Claims + Env once; the helpers flow them into every handler ctx.
const sync = defineSync<Claims, Env>()
// The schema is both the server registration AND the client's typed contract.
const schema = sync.schema({
collections: {
// The collection key is the table name; pk is the sole TEXT client-supplied key.
messages: sync.collection<Message>({
pk: "id",
// The closed mutation trio { insert?, update?, delete? } — a 4th key is a
// type error. op.cols is typed per op (full Message on insert).
mutations: {
insert: {
// authorize runs first, before any write (async ok) — throw to deny.
authorize: ({ user, op }) => {
// Only let a client write messages authored by itself.
if (op.cols.author !== user.userId) {
throw new Error("author must match the connected user")
}
},
// execute runs inside the SQLite transaction — synchronous only.
execute: ({ op, sql }) => {
const c = op.cols // typed Message, no cast
sql.exec(
"INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)",
c.id,
c.author,
c.content,
c.created_at,
)
},
},
},
}),
},
// Commands are the escape hatch for writes that aren't a single typed row op.
// Their own SQL still flows through the CDC triggers, and they return a result.
commands: {
clearRoom: sync.command()(({ sql }) => {
const before = Array.from(sql.exec("SELECT count(*) AS c FROM messages"))[0]!.c as number
sql.exec("DELETE FROM messages") // fans out to every tab as delete deltas
return { deleted: before } // returned to the caller on commit
}),
},
})
// The client type-only imports this to type its transport + collections.
export type Api = typeof schema
export class SessionDO extends SyncDurableObject<Env, Claims> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
ctx.blockConcurrencyWhile(async () => {
// You own your schema; the framework wires CDC after.
this.sql.exec(`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
author TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
)`)
this.registerSync(schema)
})
}
// Read the Worker-forged claims header into the per-socket attachment.
protected parseAttachment(req: Request): Claims {
return JSON.parse(req.headers.get("x-claims") ?? "{}") as Claims
}
}
The row type your schema is built from (the client infers it through Api, so it never imports this directly):
interface Message {
id: string
author: string
content: string
created_at: number
}
// can also be a Drizzle inferSelect, or Zod infer
The repo also has task-oriented recipes — commands vs mutations, end-to-end types, on-demand windows, server-originated writes — alongside the runnable examples.
npm i tanstack-durable-object-sync @tanstack/db
It’s early, and the repo has runnable examples (chat, a board, on-demand loading) if you want to see the whole stack end to end. Fully open source: grrowl/tanstack-durable-object-sync. If you try it or break it, I’d love to hear about it. Cheers.