durableStream keeps the stream log in an external Durable Streams backend. A reconnect can then reach any worker.
npm i @tanstack/ai-durable-streampnpm add @tanstack/ai-durable-streamyarn add @tanstack/ai-durable-streambun add @tanstack/ai-durable-streamimport {
chat,
chatParamsFromRequest,
resumeServerSentEventsResponse,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { durableStream } from '@tanstack/ai-durable-stream'
import { openaiText } from '@tanstack/ai-openai'
// Your token source.
import { getDurableStreamsToken } from './auth'
const durableOptions = {
server: 'https://streams.example.com',
streamPrefix: 'chat-runs',
headers: async () => ({
Authorization: `Bearer ${await getDurableStreamsToken()}`,
}),
}
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
})
return toServerSentEventsResponse(stream, {
durability: {
adapter: durableStream(request, durableOptions),
batch: 32,
batchWaitMs: 50,
},
})
}
export async function GET(request: Request) {
return resumeServerSentEventsResponse({
adapter: durableStream(request, durableOptions),
})
}The client code does not change.
| Option | What it does | Default |
|---|---|---|
| server | Base URL of the Durable Streams server. Optional when you pass fetch (for example, a Cloudflare service binding). | None |
| streamPrefix | Prefix for the stream names. | runs |
| headers | Headers for every create, append, read, and close request. A static object, or an async resolver that runs for every request so tokens can rotate. | None |
| fetch | The fetch implementation. | Global fetch |
| reconnect | Bounds for the read retry loop: { maxReadFailures, delayMs }. See Reconnection bounding. | { maxReadFailures: 10, delayMs: 250 } |
| operationTimeoutMs | Timeout for one create, append, or close request. snapshot is bounded by it too. A long-poll read is not. | 30000 |
| producerEpoch | Sent as Producer-Epoch on every append. A backend that fences producers rejects appends from a lower epoch, so a host that lost its claim cannot keep writing. Pass RunRecord.driverEpoch here. | 0 |
Behavior to know:
Each append to a remote backend is one request. A chunk goes to the client only after its append. Two options on durability balance the number of writes against how fast the text shows:
If your backend charges per write, increase batchWaitMs and batch. The text then arrives in bigger steps. If the reply arrives in large blocks, decrease batchWaitMs. Set it to 0 to append each chunk alone.
Reconnect after a drop is automatic. To attach to a run from the start on purpose (a second tab, or a full reload where you know the run id), call joinRun. It sends a read-only GET with offset=-1, so the server needs the GET handler from the Overview. That handler is resumeServerSentEventsResponse({ adapter }) (or resumeHttpResponse for NDJSON). It replays from the log, and it returns a 400 if the request has no resume offset.
import { fetchServerSentEvents } from '@tanstack/ai-client'
async function attach(runId: string) {
const connection = fetchServerSentEvents('/api/chat')
for await (const chunk of connection.joinRun(runId)) {
console.log(chunk)
}
}All four HTTP adapters (fetchServerSentEvents, fetchHttpStream, xhrServerSentEvents, xhrHttpStream) have joinRun.
The producer of a durable run is not tied to the delivery socket. When the client disconnects (a page reload, a dropped connection), the response is cancelled, but the run keeps writing into the log until its own terminal. A reconnect or a joinRun at mount then reads it to the end. That is why a run resumes after a full reload, not only after a reconnect in the same session.
If the replay ends on a client tool, the client sends the tool result after the replay finishes. That is the same path as a live stream.
A run ends early only on a real cancel or a failure:
In both cases, the producer writes a terminal and then awaits close() on exit. A reconnecting or joining client then sees a terminal and does not hang.
If the append of that terminal or the close fails, the server logs the cause by default. A joining client only sees a generic incomplete error, so the server log has the real cause. The server logs a failed replay the same way, for example a reconnect to an expired run. On SSE and NDJSON, that reader also gets a RUN_ERROR. Pass debug to send these logs to your own logger:
import { memoryStream, toServerSentEventsResponse } from '@tanstack/ai'
import type { StreamChunk } from '@tanstack/ai'
function respond(request: Request, stream: AsyncIterable<StreamChunk>) {
return toServerSentEventsResponse(stream, {
durability: { adapter: memoryStream(request) },
debug: true, // or { logger } for a custom Logger
})
}A durable source must end with its own terminal event (RUN_FINISHED or RUN_ERROR). On normal completion, core terminalizes the log only if the source emitted a terminal. Without one, a durable consumer reconnects once, gets nothing new, and fails with DurableStreamIncompleteError. chat() always emits RUN_FINISHED, so this affects only streams that you build yourself.
In one live process, memoryStream survives a client disconnect. The producer outlives the socket and keeps writing into the log, so a later reconnect or joinRun resumes a run that still goes on. Two limits keep it to development and single-process deployments:
More memoryStream behavior:
durableStream removes limit 1: the log spans processes.
A dropped connection resumes from the last offset. A transport error retries as long as the client holds an offset, even if that attempt delivered only the replayed overlap.
How a stream that ends cleanly is treated:
This rule exists because a clean close means the server ended the response. So a durable transport must never end an empty long-poll window while the producer is alive.
The client waits between attempts and limits reconnects with maxAttempts. Past the limit, it fails with StreamReconnectLimitError. The limit counts only consecutive reconnects that deliver no new events, and any new event resets it to zero. A healthy long run never reaches it, even behind a proxy that closes the socket after each event. It fires only when a run is stuck.
import { fetchServerSentEvents } from '@tanstack/ai-client'
function makeConnection() {
return fetchServerSentEvents('/api/chat', {
reconnect: { maxAttempts: 5, initialDelayMs: 250, maxDelayMs: 5000 }, // defaults shown
})
}The wait doubles after each reconnect that delivers nothing: 250 ms, 500 ms, 1 s, and up to maxDelayMs. Jitter takes each wait from half of that value up to the full value, so many clients do not reconnect at the same moment.
A reconnect also retries a response with a temporary status: 408, 429, 502, 503, or 504. A proxy sends these while the server behind it restarts. If the response has a Retry-After header, the client waits that long, up to maxDelayMs. Any other non-2xx status, for example 401 or 404, fails at once.
A restart or a rolling deploy can take tens of seconds. With the defaults, the client gives up after about 8 seconds. To keep open chats alive through a deploy, set budgetMs:
import { fetchServerSentEvents } from '@tanstack/ai-client'
function makeConnection() {
return fetchServerSentEvents('/api/chat', {
reconnect: { budgetMs: 120_000 },
})
}With budgetMs, the client reconnects with no attempt limit until the time is used. The budget starts at the first failed reconnect, and any new event resets it. If the next wait would go past the budget, the run fails with StreamReconnectLimitError. To also limit the attempts, set maxAttempts too.
durableStream limits its own read loop the same way. After a body read fails in the middle of a window, it retries from the last valid position, and it limits consecutive failures (reconnect: { maxReadFailures: 10, delayMs: 250 }). Normal long-poll progress is never throttled.
A WebSocket stays open for the whole conversation, so some things are different from SSE and NDJSON. WebSockets has the full protocol and code. What changes:
A dropped WebSocket aborts the chat() call behind it, with memoryStream and with durableStream. A reconnect then replays the log up to the drop. It does not resume the model call. To keep a reply going across a drop, use SSE or NDJSON with durableStream.
StreamDurability<TOffset> owns its offset format. Core only passes the returned values back to that adapter and writes them to the wire: an SSE id: field, or the id of an NDJSON { id, chunk } envelope. For each appended batch:
Core never makes an offset from an array index, and never writes one onto the StreamChunk.
A producer that restarts in the middle of a run and replays part of its source must not append the overlap again. The client cannot protect you. A chunk appended again gets a new offset, so the client's offset de-duplication does not catch it. The stream processor then joins the text deltas and tool-call arguments, and the user sees duplicated text and {"a":1}{"a":1} arguments.
Use snapshot() plus a normal append. Read what the log already holds, compare it with your replay, skip the matching prefix, and append only the rest:
import { memoryStream } from '@tanstack/ai'
import type { StreamChunk } from '@tanstack/ai'
async function appendAfterStored(
request: Request,
replayed: Array<StreamChunk>,
) {
const durability = memoryStream(request)
const stored = await durability.snapshot()
// `snapshot` returns at once, even while the log is open, so this works on a
// log whose previous producer died without calling `close()`.
const remainder = replayed.slice(stored.length)
if (remainder.length > 0) await durability.append(remainder)
}@tanstack/ai-sandbox does this for a replayed agent run. It compares by a fingerprint that ignores timestamps, not by count, and it throws if the replay and the log differ inside the prefix. See The Run Journal.
The log stays append-only with offsets from the adapter, so this also works on durableStream, the recommended production adapter, which has no upsert. Two limits of durableStream.snapshot():
upsert stays an optional capability for adapters whose store can write at a key that the caller chooses. The resume path above does not use it, and most apps never need it. memoryStream has it. durableStream does not. To build your own, see Re-persisting a stored range.
Durable Streams has a backend for Cloudflare Workers plus Durable Objects that speaks this protocol. durableStream talks to it directly, with no new adapter.
When your TanStack AI endpoint also runs on Workers, connect to the backend through a service binding, not a public URL. The adapter's fetch option sends each request through the binding. Traffic stays on the Cloudflare network, and the binding (not a bearer token) authorizes the call:
import { durableStream } from '@tanstack/ai-durable-stream'
interface Env {
// A service binding to the deployed Durable Streams Worker.
DURABLE_STREAMS: { fetch: typeof fetch }
}
function cloudflareAdapter(request: Request, env: Env) {
// No `server` here: the binding routes by path, so the adapter uses an
// internal placeholder base and only the `/streams/...` path matters.
return durableStream(request, {
streamPrefix: 'chat-runs',
fetch: env.DURABLE_STREAMS.fetch.bind(env.DURABLE_STREAMS),
})
}If the backend runs somewhere else, set server to the public URL of the Worker:
import { durableStream } from '@tanstack/ai-durable-stream'
function urlAdapter(request: Request) {
return durableStream(request, {
server: 'https://durable-streams.example.workers.dev',
streamPrefix: 'chat-runs',
})
}A Durable Object also gives you the lease and reaper from Process death. A DO alarm can terminalize a run whose producer died, so a reconnecting client sees a terminal state and does not wait forever.
A process that has stopped cannot run cleanup code. So finally or close() alone cannot handle real process death. A production backend adds a lease and a reaper:
This belongs to the durability service or the deployment, not to the response helper in the process.
A sandboxed run has its own mechanism. Its work continues inside the sandbox, not in the dead process, so a later request can adopt it. sandboxRunDriver claims the run, fences out the host that died, and continues to append to the same log. That is a takeover of a live producer, not the terminalization of a dead one, and it applies only to sandboxed runs. See Takeover & Detached Runs. memoryStream cannot take part, because its log lives in the process of the producer, and nothing survives that process.
The durability log replays chunks. It is not a source of truth that you can query for thread messages or history. It answers "what did this run stream?", not "what did this user say?". Keep the authoritative state in your own storage. See Persistence and Client persistence.