# Resumable Streams
URL: /docs/guides/resumable-streams
Persist an in-flight LLM response on the server so the client can reload, lose its connection, or open a new tab and pick up the same stream.
`assistant-stream/resumable` lets you continue a streaming LLM response across client reconnects. The server keeps writing to a store while the original request is in flight; if the browser reloads or loses its connection, a follow-up request replays the persisted bytes plus any new ones until the producer finalizes.
It works with any encoder that already ships in `assistant-stream` (the AI SDK UI message stream, the data stream protocol, the assistant transport SSE format, or your own), because persistence happens at the byte level after encoding.
## What it solves \[#what-it-solves]
A user sends a long prompt, walks away, and reloads the tab. Without resumable streams the LLM call is wasted; with them the client picks up where it left off. The same flow handles dropped mobile connections and lets a stream started on one device be read on another, gated by an opaque stream id.
If your responses are short or you do not care about reload survival, the standard `streamText().toUIMessageStreamResponse()` path is enough.
## Server side: minimum wiring \[#server-side-minimum-wiring]
Construct a `ResumableStreamContext` once per process and reuse it across requests. The context is the seam between your route handlers and the storage backend.
```ts title="/lib/resumable-context.ts"
import {
createInMemoryResumableStreamStore,
createResumableStreamContext,
} from "assistant-stream/resumable";
const store = createInMemoryResumableStreamStore();
export const resumableContext = createResumableStreamContext({ store });
```
In your chat route, wrap the response body in `ctx.run(streamId, makeStream)`. The first caller for `streamId` becomes the producer (your `makeStream` callback runs); later callers and reconnects become consumers that replay the persisted bytes.
```ts title="/app/api/chat/route.ts"
import { streamText } from "ai";
import { RESUMABLE_STREAM_ID_HEADER } from "assistant-stream/resumable";
import { resumableContext } from "@/lib/resumable-context";
export async function POST(req: Request) {
const { messages } = await req.json();
const streamId = crypto.randomUUID();
const result = streamText({ /* model, messages, tools, ... */ });
const sourceBody = result.toUIMessageStreamResponse().body!;
const stream = await resumableContext.run(streamId, () => sourceBody);
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
[RESUMABLE_STREAM_ID_HEADER]: streamId,
},
});
}
```
A separate GET endpoint replays the persisted bytes for reconnecting clients. `ctx.resume(streamId)` returns `null` when no stream exists; use `ctx.requireResume(streamId)` if you prefer to surface a `ResumableStreamError` with code `"missing"` instead.
```ts title="/app/api/chat/resume/[streamId]/route.ts"
import { RESUMABLE_STREAM_ID_HEADER } from "assistant-stream/resumable";
import { resumableContext } from "@/lib/resumable-context";
export async function GET(
_req: Request,
ctx: { params: Promise<{ streamId: string }> },
) {
const { streamId } = await ctx.params;
const stream = await resumableContext.resume(streamId);
if (!stream) {
return new Response(JSON.stringify({ error: "stream not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
[RESUMABLE_STREAM_ID_HEADER]: streamId,
},
});
}
```
The context exposes two more verbs: `ctx.status(streamId)` returns `"streaming" | "done" | "error" | "missing"`, and `ctx.delete(streamId)` removes all persisted state for a stream and terminates active readers. The remaining options on `createResumableStreamContext` (`onAcquire`, `onAppend`, `onFinalize`, `onError`) are observability hooks covered in [Resumable Stream Deployment](/docs/guides/resumable-stream-deployment).
## Client side: native integration \[#client-side-native-integration]
`@assistant-ui/react-ai-sdk` ships a `resumable` option on `AssistantChatTransport`. It captures the stream id from the response header, redirects `chat.resumeStream()` reconnects to your resume route, and clears the stored id when the response finishes naturally. Pair it with `useChatRuntime`, which fires `chat.resumeStream()` on mount whenever a pending id is present in storage.
```tsx title="/app/page.tsx"
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import {
AssistantChatTransport,
createResumableSessionStorage,
useChatRuntime,
} from "@assistant-ui/react-ai-sdk";
import { useMemo } from "react";
import { Thread } from "@/components/assistant-ui/thread";
const storage = createResumableSessionStorage();
export default function Page() {
const transport = useMemo(
() =>
new AssistantChatTransport({
api: "/api/chat",
resumable: {
storage,
resumeApi: (streamId) => `/api/chat/resume/${streamId}`,
},
}),
[],
);
const runtime = useChatRuntime({ transport });
return (
);
}
```
`createResumableSessionStorage` returns a `ResumableClientStorage` backed by `window.sessionStorage`. Pass `{ key }` to namespace per route or per chat surface, or supply your own implementation of the three methods (`getStreamId`, `setStreamId`, `clear`). If you are running on a transport that already wraps `fetch` or `prepareReconnectToStreamRequest`, the `resumable` option composes with your existing handlers.
The default finish detector scans the SSE body for the AI SDK `"type":"finish"` marker. Override `isFinishEvent` on the `resumable` option when you ship a custom encoder.
## Storage choices \[#storage-choices]
The core package ships `createInMemoryResumableStreamStore` for development and tests. State lives in a process-local `Map`, so it does not survive a server restart. Useful options include `defaultTtlMs`, `maxChunkBytes`, `maxEntriesPerStream`, `maxStreams`, and `gcIntervalMs` for periodic eviction.
For production, use one of the optional Redis adapters via the `assistant-stream/resumable/redis` (node-redis v5) or `assistant-stream/resumable/ioredis` sub-paths. Both adapters batch the per-append `XADD` and TTL refresh into a single pipelined round trip, store chunk values as binary, and accept the same `keyPrefix`, `defaultTtlMs`, `pollIntervalMs`, and `maxChunkBytes` options. Cluster routing works because each stream's keys share a `{streamId}` hash tag.
```ts title="/lib/resumable-context.ts"
import {
createResumableStreamContext,
type ResumableStreamStore,
} from "assistant-stream/resumable";
async function createStore(): Promise {
if (!process.env.REDIS_URL) {
const { createInMemoryResumableStreamStore } = await import(
"assistant-stream/resumable"
);
return createInMemoryResumableStreamStore();
}
const { createClient } = await import("redis");
const { createRedisResumableStreamStore } = await import(
"assistant-stream/resumable/redis"
);
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
return createRedisResumableStreamStore(client);
}
export const resumableContext = createResumableStreamContext({
store: await createStore(),
});
```
For Postgres, Cloudflare Durable Objects, Upstash REST, or any other backend, implement the `ResumableStreamStore` interface directly. See [Custom Resumable Stream Stores](/docs/guides/resumable-stream-stores) for the contract walkthrough and a worked example.
## Production checklist \[#production-checklist]
* **Auth.** The resume route in the snippets above will serve any caller that knows the stream id. Bind `streamId` to the requesting user at acquire time and verify the binding inside the resume handler. Treat the id as opaque, not as a credential; it leaks via response headers, `sessionStorage`, browser history, and access logs.
* **`waitUntil` on serverless.** On Vercel and Cloudflare the request handler is killed once the response returns, which interrupts the producer task. Pass `after` from `next/server` (or your platform's `ctx.waitUntil`) when constructing the context so the task survives past the response: `createResumableStreamContext({ store, waitUntil: after })`.
* **TTL.** Streams expire 24 hours after the last write by default. Configure with `defaultTtlMs` on the store, or override per deployment via `ttlMs` on the context. Match TTLs across the store, any owner-binding key, and any signed cookie that references a `streamId`.
* **Stream id format.** The Redis adapters validate `streamId` against `/^[A-Za-z0-9_.:-]{1,256}$/` to keep keys well-formed. UUIDv4 is fine.
For the full treatment of authorization, multi-tenant key prefixes, observability hooks, resource limits, and incident response, see [Resumable Stream Deployment](/docs/guides/resumable-stream-deployment).
A new `ResumableStreamError` class is exported from `assistant-stream/resumable` with codes `"missing" | "exists" | "finalized" | "invalid-id"`; catch it in the resume route to distinguish "stream gone" from other failures.
## Helpers for `AssistantStreamController` callbacks \[#helpers-for-assistantstreamcontroller-callbacks]
If you produce streams via `createAssistantStream` rather than the AI SDK, the package ships two helpers that bridge the controller-callback style and any encoder to the store:
```ts
import {
createResumableAssistantStreamResponse,
createResumeAssistantStreamResponse,
} from "assistant-stream/resumable";
import { resumableContext } from "@/lib/resumable-context";
// POST handler
return createResumableAssistantStreamResponse({
context: resumableContext,
streamId,
callback: (controller) => {
/* same shape as createAssistantStreamResponse */
},
});
// GET resume handler
return createResumeAssistantStreamResponse({
context: resumableContext,
streamId,
});
```
Both helpers default to the data-stream encoder; pass `encoder: () => new AssistantTransportEncoder()` (or any custom encoder) to override. They set the `x-resumable-stream-id` response header automatically, which is what `AssistantChatTransport`'s `resumable` adapter looks for.
## Example app \[#example-app]
[`examples/with-resumable-stream`](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-resumable-stream) is a runnable Next.js app that uses `useChat`, the `resumable` transport option, and `useChatRuntime`. It falls back to a built-in mock when `OPENAI_API_KEY` is unset, and switches the store from in-memory to Redis when `REDIS_URL` is set.
```sh
npx assistant-ui create my-app -e with-resumable-stream
```