with-resumable-stream
Demonstrates assistant-stream/resumable: persist an in-flight LLM response on the server so the client can reload or lose its connection mid-stream and pick up where it left off.
How it works
- Each
POST /api/chatmints a new UUIDv4streamIdserver-side and returns it in thex-resumable-stream-idheader.AssistantChatTransportreads that header on the browser and stores the latest pending id insessionStorage. - The route runs
streamText, then pipes the byte body throughcontext.run(streamId, () => uiMessageStreamBody). The bytes are persisted in the configured store while being streamed to the client. - If the client disconnects mid-stream, the server-side producer task keeps running and continues to append bytes to the store.
- On page load, if
sessionStoragestill has a stream id, the client callschat.resumeStream(). The transport'sresumableadapter rewrites the AI SDK reconnect request toGET /api/chat/resume/[streamId], which callscontext.resume(streamId)and replays the persisted bytes (plus any new ones) to the reconnecting client. - When the response stream closes naturally, the transport detects the AI SDK
finishSSE event in the body and clears the stored id. Cancellation does not clear the id, so a reload mid-stream still finds it on next mount.
Quick start
cp .env.example .env.local
# Optional: put your OPENAI_API_KEY in .env.local for the real model. Without
# it, the example falls back to a slow built-in mock so you can demo resume.
pnpm install
pnpm dev
Open http://localhost:3000, send a prompt, then reload the page mid-response. The same answer keeps filling in.
Client integration
The browser side is wired through two helpers from @assistant-ui/react-ai-sdk:
createResumableSessionStorage()returns a smallsessionStorage-backed storage object for the pending stream id. Pass{ key }to namespace per route. UselocalStorageinstead if you want a stream that survives full browser restarts (and accept the cross-tab race that comes with it).AssistantChatTransportaccepts aresumableoption. When set, the transport captures thex-resumable-stream-idresponse header, watches the SSE body for thefinishevent so the stored id is cleared on natural completion (cancellation leaves it intact), and redirectschat.resumeStream()reconnects to the configuredresumeApi.
The example fires chat.resumeStream() from a one-shot useEffect when storage already has an id at mount time. If you switch to useChatRuntime, that resume call is wired automatically.
Server methods
The ResumableStreamContext returned by createResumableStreamContext exposes:
run(streamId, makeStream): producer-or-consumer entrypoint. Auto-elects the role via the store, so concurrent callers with the same id share the persisted output.resume(streamId): strict consumer; returnsnullwhen the stream does not exist.requireResume(streamId): same asresume, but throwsResumableStreamError("missing")instead of returningnull.status(streamId): probe the lifecycle state without opening a reader.delete(streamId): remove all state for a stream.
Storage
By default the example uses createInMemoryResumableStreamStore from assistant-stream/resumable. The store is memoized on globalThis via Symbol.for, so it survives Next.js hot reload but not full server restart. Default TTL is 24h; configure with defaultTtlMs if you want shorter eviction.
To use Redis instead, set REDIS_URL in .env.local:
REDIS_URL=redis://localhost:6379
OPENAI_API_KEY=your-api-key-here
The example will use the assistant-stream/resumable/redis adapter automatically. With Redis, resumable state survives server restarts (until the per-stream TTL expires; default 24h).
You can run a local Redis quickly with Docker:
docker run -d --name redis -p 6379:6379 redis:7-alpine
Production checklist
GET /api/chat/resume/[streamId] in this example serves any caller who knows the stream id. UUIDv4 is unguessable in practice, but the id leaks into response headers, sessionStorage, browser history, and access logs. Before deploying:
- Gate the resume endpoint with the same auth your chat endpoint uses.
- Bind
streamIdto the requesting user at creation time and reject mismatches before callingcontext.resume. - On serverless platforms that kill the request handler when the response returns (Vercel, Cloudflare), pass
waitUntilfromnext/server(after) when constructing the context. Otherwise the producer task is interrupted and resume cannot recover the rest of the stream.
Files of interest
lib/resumable-context.ts: picks the store based onREDIS_URLand memoizes a singleResumableStreamContextacross module reloads.app/api/chat/route.ts: POST endpoint that mints a freshstreamId, runsstreamText(or a mock), and wraps the response body incontext.run.app/api/chat/resume/[streamId]/route.ts: GET endpoint that callscontext.resumeand replays the persisted bytes.app/page.tsx: wiresuseChatwithAssistantChatTransport'sresumableadapter and triggerschat.resumeStream()on mount when a pending id exists.