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:

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.

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…

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.

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.