TanStack
Resumable Streams

Resumable Streams (Advanced)

Resumable Streams: Advanced

The Overview covers the common case: pick an adapter, wrap your response, add a GET handler. This page covers the rest.

durableStream options

durableStream(request, options) talks to an external Durable Streams backend:

ts
import {
  chat,
  chatParamsFromRequest,
  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-5.5'),
    messages,
    threadId,
    runId,
  })
  return toServerSentEventsResponse(stream, {
    durability: { adapter: durableStream(request, durableOptions), batch: 32 },
  })
}
  • headers takes a static object for fixed credentials or an async resolver for rotating tokens. The resolver runs for every create, append, read, and close.
  • batch controls how many chunks are buffered per log append (default 32).
  • The backend must return a non-empty Stream-Next-Offset header on create, append, and close. A missing header fails loudly. The adapter never guesses an offset.

Attaching to a run by id

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 already know the run id), call joinRun. It performs a read-only GET with offset=-1, which is why the server needs the GET handler from the Overview. That handler is just resumeServerSentEventsResponse({ adapter }) (or resumeHttpResponse for NDJSON): it replays from the log and returns a 400 if the request has no resume offset.

ts
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) expose joinRun.

Disconnect, stop, and errors

A durable run's producer is decoupled from the delivery socket. When the client disconnects (a page reload, a dropped connection), the response is cancelled but the run keeps draining into the log to its own terminal, so a reconnect or a mount-time joinRun tails it to completion. This is what makes a run resumable across a full reload, not just an in-session reconnect.

A run ends early only on a genuine cancel or a failure:

  • Cancel — an AbortController you pass to the response as abortController and then abort (from a user Stop button, or by forwarding request.signal, which is an AbortSignal, onto a controller of your own). This stops the producer and appends a terminal RUN_ERROR. A bare client disconnect does not do this; pass a controller when you want a disconnect to also stop the run.
  • Provider failure — the model stream throws; the error is appended as a terminal RUN_ERROR.

Either way the producer awaits close() on exit, and a terminal is written before closing so a reconnecting or joining client sees a terminal instead of hanging. If appending that terminal or closing fails, the cause is logged server-side by default (a joiner only ever sees a generic incomplete error, so the server log is where the real cause lives). Pass debug to route it to your own logger:

ts
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/RUN_ERROR). On normal completion the log is terminalized only if the source emitted a terminal; without one, a durable consumer reconnects once, makes no progress, and fails with DurableStreamIncompleteError. chat() always emits RUN_FINISHED, so this only affects hand-rolled streams.

memoryStream in production

Within a single live process memoryStream already survives a client disconnect: the producer outlives the socket and keeps draining into the log, so a later reconnect or joinRun resumes a still-running run. Two things still keep it to development and single-process deployments:

  1. The log lives in one process's memory, so a reconnect that lands on a different worker finds nothing.
  2. If the process itself dies, the log dies with it, so an interrupted run can neither resume nor terminalize on its own. A production backend adds a lease/reaper (see Process death) that an in-memory process cannot.

Completed runs are evicted after a grace window, so resuming an expired or unknown run fails loudly instead of hanging, and a from-start join to a run that never produces fails after firstChunkDeadlineMs. Spanning processes (point 1) is what durableStream adds.

Reconnection bounding

A dropped connection resumes from the last offset. A transport error retries as long as an offset is held, even if that attempt delivered only the replayed overlap. A durable run that ends cleanly without a terminal and makes no forward progress fails with DurableStreamIncompleteError. Only a non-durable (untagged) stream that ends cleanly counts as a completed run. The distinction is deliberate: 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 throttles between attempts and bounds reconnection with maxAttempts, failing with StreamReconnectLimitError. The ceiling counts only consecutive reconnects that deliver no new events; forward progress resets it to zero. A healthy long run, even one behind a proxy that rolls the socket after every event, never approaches it. It fires only when a run is genuinely stuck.

ts
import { fetchServerSentEvents } from '@tanstack/ai-client'

function makeConnection() {
  return fetchServerSentEvents('/api/chat', {
    reconnect: { maxAttempts: 5, delayMs: 250 }, // defaults shown
  })
}

durableStream bounds its own read loop the same way. After a mid-window body read failure it retries from the last valid position, capping consecutive failures (reconnect: { maxReadFailures: 10, delayMs: 250 }). Normal long-poll advancement is never throttled.

Offset ownership

StreamDurability<TOffset> owns its offset format. Core only passes 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 every appended batch:

  1. core calls append(chunks) before forwarding the chunks;
  2. the adapter returns exactly one offset per chunk, in order;
  3. core rejects missing, extra, empty, or whitespace/CR/LF-containing offsets;
  4. a resume reads strictly after the supplied offset.

Core never derives an offset from an array index and never stamps one onto the StreamChunk. See Custom Durability Adapter to build your own.

Cloudflare Durable Streams

Durable Streams ships a Cloudflare Workers plus Durable Objects backend that speaks this protocol, so durableStream talks to it directly with no new adapter.

When your TanStack AI endpoint also runs on Workers, reach the backend over a service binding instead of a public URL. The adapter's injectable fetch routes every request through the binding, so traffic stays on Cloudflare's network and the binding (not a bearer token) authorizes the call:

ts
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` needed: 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 elsewhere, point server at the Worker's public URL instead:

ts
import { durableStream } from '@tanstack/ai-durable-stream'

function urlAdapter(request: Request) {
  return durableStream(request, {
    server: 'https://durable-streams.example.workers.dev',
    streamPrefix: 'chat-runs',
  })
}

Running on a Durable Object also satisfies the lease/reaper described under Process death: a DO alarm can terminalize a run whose producer died, so a reconnecting client sees a terminal state instead of waiting forever.

Process death

A process that has already terminated cannot run cleanup code, so literal process death cannot be guaranteed by finally or close() alone. Production backends should add a lease/reaper:

  1. the producer acquires or renews a lease while writing;
  2. a timer, alarm, or background worker detects expired leases;
  3. the reaper records an aborted terminal state and closes the log;
  4. readers observe that terminal state instead of waiting forever.

This belongs to the durability service or deployment, not the in-process response helper.

Delivery is not state

The durability log replays chunks. It is not a queryable source of truth for thread messages or conversation history. It answers "what did this run stream?", not "what has this user said?". Keep authoritative state in your own storage. See Client persistence for the client-side options.