Super Simple Sync with Durable State and TanStack DB — Streamsy
Super Simple Sync with Durable State and TanStack DB
Sync engines are taking off. In 2026 you are spoiled for choice: Zero, Electric, PowerSync, and LiveStore, to name a few. However, what if you don't need a full sync engine? Maybe you just need a little sync, or you want to build your own. Thanks to what Claude wants to call "the great unbundling" of ElectricSQL, we don't have to choose between a full sync engine and rolling our own
First, let's quickly review what we mean by a sync engine. There are really two ways to look at it:
From the developer perspective, a sync engine decouples data transport from local state management. It allows you to write your application as if the state were local, while the sync engine handles the details of keeping that state in sync with a remote source.
From the user perspective, a sync engine provides a local-first experience, with instantaneous loads and real-time updates.
The three pieces
TanStack DB: the local database
The easiest way to think about TanStack DB is that it solves the client-side part of the sync engine. It gives us:
typed local collections;
live, reactive queries;
optimistic mutations;
one local place to merge confirmed server changes.
The UI mostly does not care whether an item arrived during initial catch-up, live tailing, or an optimistic mutation. It reads from local collections. A sync adapter is responsible for keeping the collections' confirmed state aligned with the server.
Durable Streams: the transport
Durable Streams is a protocol for persistent, addressable append-only streams over HTTP. It takes care of the low-level details for reading and writing to streams. The key feature is resumable catch-up and live reads over SSE. Reading from a stored offset handles both scenarios with the same protocol. The combination of append-only streams with resumable reads is useful in a surprising number of applications (durable LLM responses, chat logs, event sourcing, etc.), and is all we need to build a sync protocol.
Durable State: the contents of the stream
A byte stream is not yet a sync protocol. The client needs to know whether a message inserts, updates, or deletes an item, and which local collection owns it.
Durable State supplies that collection-change vocabulary. An illustrative change might look like this:
"type": "task",<br>"key": "task:456",<br>"value": {<br>"id": "task:456",<br>"projectId": "project:123",<br>"title": "Write launch checklist",<br>"status": "open"<br>},<br>"headers": {<br>"operation": "insert",<br>"txid": "01K2..."
Where:
type selects a collection;
key identifies an entity in that collection;
operation is insert, update, or delete;
value is the new entity value when applicable;
txid can correlate all changes produced by one accepted mutation.
A single ordered stream can contain several entity types. type + key is enough to route each change into the correct local record.
Putting it all together
On the client, we need to synchronise local TanStack DB collections with the remote state. We could write our own sync adapter by providing a sync function:
const sync: SyncConfigT>["sync"] = (params) => {<br>const { begin, write, commit, markReady, collection } = params;
// Connect to remote stream<br>// Read from the stream and write to the local collection
// Return a cleanup function<br>return () => {<br>connection.close();<br>};<br>};
However, the wonderful folks behind Durable Streams have already done this for us with StreamDB. We simply need to provide a schema and the URL of our Durable State stream.
import { createStreamDB } from "@durable-streams/state/db";
const db = createStreamDB({<br>streamOptions: {<br>url: "https://api.example.com/streams/my-stream",<br>contentType: "application/json",<br>},<br>state: schema,<br>});
await db.preload();
The client schema describes the same task collection and entity shape that the server registers below.
On the server, we need a Durable Streams server implementation — for example Streamsy, or any other implementation. With Streamsy it looks like this:
import {<br>createHttpHandler,<br>createMemoryStorageAdapter,<br>createStreamProtocol,<br>} from "@streamsy/core";<br>import { createDurableStateProtocol } from "@streamsy/state";<br>import { taskCodec } from "./schema";
const adapter = createMemoryStorageAdapter();<br>const protocol = createStreamProtocol({ storage: { adapter } });<br>const state = createDurableStateProtocol(protocol, {<br>tasks: { type: "task", schema: taskCodec, primaryKey: "id" },<br>});
await state.create("my-stream");
const handler = createHttpHandler({<br>protocol: state.protocol,<br>pathPrefix: "/streams",<br>});
Bun.serve({ fetch: (request) => handler.fetch(request) });
This uses the in-memory adapter for brevity; swap in Streamsy's @streamsy/storage-sqlite adapter for a real deployment.
Ch-ch-ch-ch-changes
The astute reader will have noticed that we have not yet discussed how server-side state is updated. The hand-wavy answer is that we append the same...