
How AI Chat Apps Survive a Page Refresh: Resumable Streams, Redis Pub/Sub and Replay
Follow one long AI answer through a page refresh: how the server keeps generating after the tab disconnects, how a second request finds the running stream through Redis pub/sub, and how the client replays it without duplicate text.
By Ghazi Khan
You ask a chat app for a long answer: a full migration plan, a 300-line component, a deep research summary. Halfway through, you hit refresh by accident, or your phone switches from Wi-Fi to mobile data. In a basic streaming setup, the answer is gone. The HTTP response was the only pipe between the model and your screen, and you just cut it.
Better chat apps handle this differently. You reload, and the answer is still being written, picking up right where it was. This case study takes that behaviour apart using the open-source reference implementation behind the Vercel AI SDK's useChat resume option and the resumable-stream package. Because both are open source, most of what follows is read straight from the code, not guessed.
Monday's post, Streaming LLM Responses in Node.js: Disconnects, Backpressure and Mid-Stream Errors, made one choice: when the user leaves, abort the model call so you stop paying for tokens nobody reads. This post makes the opposite choice, and shows what you have to build once "user left" no longer means "stop".
TL;DR
A resumable stream separates generation from delivery. The model call runs to completion on the server no matter what happens to the browser connection, and the browser becomes just one subscriber that can leave and come back. The design decision that makes it work cheaply is that the producer keeps the chunks it has generated in its own memory and uses Redis only for coordination: one counter key to tell "new stream" from "someone wants to rejoin", and pub/sub channels to ship the buffered text to a late subscriber. On the client, a resumed stream is treated as a full replay, so the UI rebuilds the answer from the start instead of trying to stitch two halves together.
What the user sees
From the browser, a resumable chat behaves like this:
- You send a message. Text starts streaming in, same as any chat app.
- You refresh, close the tab, or lose the network for a few seconds.
- You come back. The page loads the saved conversation, and the half-written answer reappears and keeps growing.
- If you open the same chat in a second tab, both tabs show the answer streaming at the same time.
- Pressing Stop actually stops the answer. Refreshing does not.
In DevTools you see the usual long-lived POST /api/chat with a growing text/event-stream response. After a reload, a GET /api/chat/<chatId>/stream either returns 204 No Content (nothing running) or a fresh event stream that starts again from the first chunk.
Requirements
Here is how an interviewer would frame it.
Functional
- Stream an assistant answer token by token.
- If the client disconnects (reload, tab close, network drop), generation keeps going on the server.
- A returning client reconnects to the in-progress answer and sees all of it, with no gaps and no duplicated text.
- Multiple clients can watch the same stream.
- An explicit Stop cancels generation; a refresh does not.
- When generation finishes, the full answer is saved, so later loads read from the database, not the stream.
Non-functional
- Latency: the happy path (nobody reconnects) must cost almost nothing extra. Time to first token should not move.
- Scale: works on serverless functions behind a load balancer with no sticky sessions, so the reconnect request may land on a different instance than the one generating.
- Cost: keep Redis traffic and memory tiny in the common case. Accept that you pay for tokens even when nobody is watching.
- Reliability: a lost pub/sub message must not leave a client hanging forever. Resumption must be authorised the same way the original request was.
High-level architecture
Diagram100%flowchart LR subgraph Browser UC["useChat with resume: true"] end subgraph Instance_A["Function instance A (producer)"] POST["POST /api/chat"] ST["streamText to model"] BUF["In-memory chunk buffer"] end subgraph Instance_B["Function instance B (consumer)"] GET["GET /api/chat/:id/stream"] end DB[("Chat store<br/>messages + activeStreamId")] R[("Redis<br/>sentinel key + pub/sub")] LLM["Model provider"] UC -- "1. send message" --> POST POST --> ST --> LLM LLM -- "tokens" --> ST --> BUF BUF -- "SSE (first connection)" --> UC POST -- "save activeStreamId" --> DB POST -- "SET sentinel, SUBSCRIBE request channel" --> R UC -- "2. after reload" --> GET GET -- "read activeStreamId" --> DB GET -- "INCR sentinel, PUBLISH request" --> R R -- "request" --> BUF BUF -- "PUBLISH buffered + live chunks" --> R R -- "chunks" --> GET GET -- "SSE replay" --> UCvisualized by
The walkthrough:
- Browser.
useChatsends the new message with aPOST. Withresumeenabled, it also sends aGETto/api/chat/<id>/streamevery time the chat component mounts. - Producer (POST handler). Calls
streamText, converts the result to the AI SDK's UI message stream, and hands that SSE stream toresumable-streamthrough theconsumeSseStreamcallback. This instance is now the producer: it owns the model call and keeps every chunk it has produced. - Chat store. Your own database. It holds the messages and one extra field per chat:
activeStreamId. This is how a reconnect request knows which stream to join. - Redis. Not a message log. It holds one small key per stream (the sentinel) and carries pub/sub messages between producer and consumers.
- Consumer (GET handler). Can run on any instance. It looks up
activeStreamId, asks Redis to put it in touch with the producer, and pipes what comes back to the browser.
Think of the producer as a context provider and each browser connection as a subscribed component: components unmount and remount, the provider's state survives.
Deep dives
1. Decoupling generation from the request
In a normal Route Handler, the response stream and the model call share a lifetime. When the client disconnects, the runtime cancels the response, and if you wired req.signal into streamText (as Monday's post did), the model call stops too.
Resumable streams break that link on purpose. The AI SDK docs are explicit that in this setup, closing a tab, refreshing or calling stop() only closes the current HTTP connection. Generation continues.
Two pieces make that possible:
- The producer keeps reading even when nobody is listening. In
resumable-stream, the producer's read loop pushes every chunk into an array and tries to enqueue it to the original response. If the enqueue throws because the client is gone, it swallows the error and keeps reading. The stream is consumed to the end regardless. - The function stays alive after the response closes. On serverless platforms, an instance can be frozen once its response is done. The library asks you for a
waitUntilfunction, and in Next.js you passafterfromnext/server. Next.js documents thatafterruns for the route's default or configured max duration, which means your longest answer has to fit insidemaxDuration.
Because a disconnect no longer means "stop", Stop needs its own path. The AI SDK docs recommend a dedicated POST /api/chat/<id>/stop endpoint that saves the partial assistant message, cancels the producing work and clears activeStreamId, but only if it still points at the same stream, so a late stop request cannot kill a newer answer. The docs also warn against calling that endpoint from route cleanup, because then every refresh would cancel the answer and resumption would never happen.
2. The coordination protocol in Redis
This is the clever part. Reading resumable-stream v2.2.13, the whole protocol uses four kinds of Redis operation on keys prefixed resumable-stream:rs:
Diagram100%stateDiagram-v2 [*] --> Producing: producer SUBSCRIBEs request channel, then SET sentinel = "1" (24h expiry) Producing --> Producing: consumer INCRs sentinel (count > 1 means "rejoin") Producing --> Done: model stream ends, SET sentinel = "DONE" Done --> [*]: key expires after 24h Done --> Done: INCR fails on "DONE", consumer gets nullvisualized by
Step by step:
- Producer starts. It subscribes to
request:<streamId>first, then setssentinel:<streamId>to"1"with a 24-hour expiry. The order matters: the code comments explain that setting the key first could let a consumer send a request before anyone is listening, and the request would be lost. - Consumer arrives. It runs
INCRon the sentinel. If the result is greater than 1, a producer exists, so the consumer joins. If the value is"DONE",INCRfails with Redis's "not an integer" error, and the library maps that error to "stream finished". One atomic command answers both "does it exist?" and "is it finished?". - Handshake. The consumer creates a random
listenerId, subscribes tochunk:<listenerId>, then publishes{ listenerId, skipCharacters }on the request channel. - Catch-up, then live. The producer receives the request, joins its buffered chunks into one string, slices off
skipCharacters, and publishes it to the consumer's channel. From then on, every new chunk is published to each registered listener as well as to the original response. - Finish. When the model stream ends, the producer sets the sentinel to
"DONE", unsubscribes from requests, and publishes a special done marker to every listener.
Two safety nets handle failure. The consumer waits about one second for the producer's first reply (the producer always replies, even with an empty string). If nothing arrives, it errors with "Timeout waiting for ack", which is how a dead producer is detected. And because pub/sub is fire-and-forget, a resumed stream also re-checks the sentinel every 10 seconds by default (doneWatchdogIntervalMs), closing only after two consecutive checks see "DONE" or an expired key.
The README says the design minimises Redis usage when nobody reconnects: a single INCR and SUBSCRIBE per stream in that common case. The catch is visible in the same code: the chunk buffer lives in the producer process's memory. If that instance crashes mid-answer, there is nothing in Redis to replay. That is a deliberate trade, and section 7 comes back to it.
3. The client: resume on mount, replay, and visibility
On the frontend, the AI SDK keeps the API small. You pass resume: true to useChat along with the chat id and its saved messages. Under the hood, in @ai-sdk/react 4.x:
- When the first
useChatregistration for a chat mounts, and the chat is not alreadysubmittedorstreaming, it callschat.resumeStream(). The transport'sreconnectToStreamsends theGET. A204resolves tonull, and the status goes back toready. - It registers a
visibilitychangelistener. When the page becomes visible again, it resumes only if the stream had been interrupted by a network disconnect. Healthy streams are left alone, which matters on mobile where backgrounding a tab is common. - If a newer resume request starts, the older one is aborted, so two reconnect attempts never race to write the same message.
The most interesting choice is replay semantics. The resumable-stream GET handler sends the whole buffered stream, from the start chunk onwards. DefaultChatTransport assumes this by default (resumeStreamIsReplay: true): on the first start chunk of a resumed stream, it resets the interrupted assistant message and rebuilds it from the replay. That is what prevents doubled text, reasoning and tool parts.
In React Query terms, this is refetching (throw away the cache, trust the server) instead of merging a partial response into stale state. Replaying is simpler and always correct, at the cost of re-sending text the user already saw. If your backend sends only the remaining chunks, set resumeStreamIsReplay: false; the docs note that continuing an open text part only works inside the same chat instance, so a full reload still needs a replay.
Data flow: one answer, interrupted by a reload
Diagram100%sequenceDiagram participant B as Browser (useChat) participant P as Instance A (POST) participant DB as Chat store participant R as Redis participant M as Model participant G as Instance B (GET) B->>P: POST /api/chat { id, message } P->>DB: save user message, activeStreamId = null P->>M: streamText(...) P->>R: SUBSCRIBE request:s1, SET sentinel:s1 "1" P->>DB: save activeStreamId = s1 M-->>P: tokens P-->>B: SSE chunks Note over B: user presses refresh, connection closes M-->>P: tokens (buffered, no reader) B->>G: GET /api/chat/:id/stream (on mount) G->>DB: read activeStreamId = s1 G->>R: INCR sentinel:s1 returns 2 G->>R: SUBSCRIBE chunk:L1, PUBLISH request:s1 { L1 } R->>P: request from L1 P->>R: PUBLISH chunk:L1 (all buffered text) R->>G: buffered text G-->>B: SSE replay from start chunk M-->>P: more tokens P->>R: PUBLISH chunk:L1 (live) R->>G: live chunks G-->>B: SSE chunks M-->>P: finish P->>DB: onEnd: save messages, activeStreamId = null P->>R: SET sentinel:s1 "DONE", PUBLISH done marker G-->>B: stream closes, status readyvisualized by
- The POST handler saves the user message and clears any previous
activeStreamId, so an old stream cannot be resumed by mistake. - It starts
streamText, wraps the SSE output withcreateNewResumableStream, and savesactiveStreamId = s1. - The user refreshes. The original response closes; the producer keeps reading tokens into its buffer.
- The new page server-renders the saved messages and mounts
useChatwithresume: true, which sends theGET. - Instance B reads
activeStreamIdand callsresumeExistingStream(s1):INCR, subscribe, publish. - Instance A answers with the full buffer, then forwards each new chunk.
- The browser sees a
startchunk, resets the assistant message, rebuilds it from the replay, and keeps appending. - When the model finishes,
onEndsaves the final messages and clearsactiveStreamId. Later loads get a204and read from the database.
Trade-offs
| Decision | What it gives up | Why it is still chosen |
|---|---|---|
| Keep generating after disconnect | You pay for tokens nobody may ever read | Long answers are expensive to regenerate, and users expect them to survive a refresh |
| Buffer chunks in producer memory | A crashed producer loses the stream; there is nothing to replay | Near-zero Redis cost and latency when nobody reconnects, which is the common case |
| Redis pub/sub for hand-off | Messages are fire-and-forget; needs an ack timeout and a watchdog | Works across serverless instances without sticky sessions |
| Full replay on resume | Re-sends text the user already saw | Simple and always correct; avoids merge bugs with half-built message parts |
activeStreamId in your database | One more field to keep consistent, plus race handling | The resume endpoint needs a cheap lookup from chat to stream |
| Separate stop endpoint | More API surface than calling abort() | Lets a refresh and a deliberate Stop mean different things |
Producer lifetime tied to waitUntil / after | Answers longer than the function's max duration get cut off | No extra worker infrastructure for the common case |
When the last two rows hurt, move generation out of the request. The Workflow SDK runs the agent as a durable workflow that clients rejoin from a startIndex. OpenAI's background mode lets you resume a stream with starting_after a sequence_number, and keeps responses for roughly 10 minutes.
Confirmed vs inferred
Confirmed by docs or source code
- In a resumable setup, tab close, refresh or
stop()only closes the HTTP connection; generation continues (AI SDK docs). useChatwithresumesends aGETto/api/chat/[id]/streamon mount, and204means no active stream (AI SDK docs andai7.x source).useChatreconnects onvisibilitychangeonly after a network-disconnect interruption (@ai-sdk/reactsource and docs).- Resumed streams are full replays by default, reset on the first
startchunk;resumeStreamIsReplay: falsechanges that (AI SDK docs). resumable-streamis designed for serverless without sticky load balancing, and does oneINCRandSUBSCRIBEper stream in the common case (README).- The sentinel key, 24-hour expiry, request and chunk channels, the
"DONE"value, the one-second ack timeout and the 10-second default watchdog (resumable-streamv2.2.13 source). - The chunk buffer is an in-memory array in the producer process (
resumable-streamv2.2.13 source). - Next.js
afterruns within the route's max duration; OpenAI background mode resumes withstarting_afterand keeps responses about 10 minutes (official docs).
Inferred or reconstructed
- That hosted chat products which survive a reload use a design like this one. Their internals are not public; this case study describes the open-source reference implementation only.
- That a producer crash makes the in-progress answer unrecoverable. This follows from the in-memory buffer but is not stated in the docs.
- That long or agentic runs should move to a durable workflow or background mode. This is my reading of the trade-offs, not a vendor recommendation.
- The AI SDK docs say Redis stores the UI message stream. In the
resumable-streamversion read here, Redis holds the sentinel and carries pub/sub; the chunks stay in the producer.
Build a mini version in JS/TS
The smallest version that shows the idea: a Node server where POST /runs starts a generation and GET /runs/:id/stream lets any number of clients subscribe and resubscribe. Everything lives in one process, so the "Redis" part is an EventEmitter. Splitting create and subscribe into two requests has a nice side effect: the subscribe call is a plain GET, so the browser's built-in EventSource works, including its automatic reconnect.
First, the run registry. It consumes the model stream to the end, whoever is listening:
// runs.ts
import { EventEmitter } from 'node:events';
type Run = { chunks: string[]; done: boolean; bus: EventEmitter };
const runs = new Map<string, Run>();
export const hasRun = (id: string) => runs.has(id);
export function startRun(id: string, source: AsyncIterable<string>) {
const run: Run = { chunks: [], done: false, bus: new EventEmitter() };
runs.set(id, run);
(async () => {
try {
for await (const text of source) {
run.chunks.push(text);
run.bus.emit('change');
}
} catch (err) {
console.error('run failed', id, err);
} finally {
run.done = true;
run.bus.emit('change');
setTimeout(() => runs.delete(id), 60_000); // after this, read from your DB
}
})();
}
export async function* readRun(id: string, from = 0) {
const run = runs.get(id);
if (!run) return;
let i = from;
while (true) {
while (i < run.chunks.length) yield { index: i, text: run.chunks[i++] };
if (run.done) return;
await new Promise((resolve) => run.bus.once('change', resolve));
}
}
The check of run.done and the once call happen in the same synchronous tick, so no chunk can slip in between. That is the event loop doing the locking for you.
Next, the server. Each SSE event carries its chunk index as id:, so a reconnecting EventSource tells us where it stopped through the Last-Event-ID header, as the HTML spec defines:
// server.ts
import { createServer, type IncomingMessage } from 'node:http';
import { randomUUID } from 'node:crypto';
import { streamText } from 'ai';
import { hasRun, readRun, startRun } from './runs';
const MODEL = process.env.MODEL_ID!; // an AI Gateway model id
async function readJson(req: IncomingMessage) {
let body = '';
for await (const chunk of req) body += chunk;
return JSON.parse(body);
}
createServer(async (req, res) => {
const url = new URL(req.url!, 'http://localhost');
const match = url.pathname.match(/^\/runs\/([\w-]+)\/stream$/);
if (req.method === 'POST' && url.pathname === '/runs') {
const { prompt } = await readJson(req);
const id = randomUUID();
startRun(id, streamText({ model: MODEL, prompt }).textStream); // no abortSignal on purpose
res.writeHead(201, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ id }));
}
if (req.method === 'GET' && match) {
if (!hasRun(match[1])) return res.writeHead(204).end(); // EventSource stops retrying
const lastId = Number(req.headers['last-event-id'] ?? -1);
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
for await (const { index, text } of readRun(match[1], lastId + 1)) {
if (res.destroyed) break; // this reader left; the run keeps going
res.write(`id: ${index}\ndata: ${JSON.stringify(text)}\n\n`);
}
return res.end('event: done\ndata: {}\n\n');
}
res.writeHead(404).end();
}).listen(3001);
Finally, a React hook. It stores the active run id so a reload can find it again:
// useResumableAnswer.ts
import { useCallback, useEffect, useRef, useState } from 'react';
const KEY = 'active-run-id';
export function useResumableAnswer() {
const [text, setText] = useState('');
const sourceRef = useRef<EventSource | null>(null);
const follow = useCallback((id: string) => {
sourceRef.current?.close();
setText(''); // a fresh connection replays from chunk 0
const source = new EventSource(`/runs/${id}/stream`);
source.onmessage = (e) => setText((t) => t + JSON.parse(e.data));
source.addEventListener('done', () => {
source.close();
localStorage.removeItem(KEY);
});
sourceRef.current = source;
}, []);
useEffect(() => {
const id = localStorage.getItem(KEY);
if (id) follow(id); // resume on mount, like useChat's resume option
return () => sourceRef.current?.close();
}, [follow]);
const ask = async (prompt: string) => {
const res = await fetch('/runs', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
const { id } = await res.json();
localStorage.setItem(KEY, id);
follow(id);
};
return { text, ask };
}
This hook shows both resume modes. A reload creates a new EventSource with no last event id, so the server replays everything into empty state. A network blip lets EventSource reconnect on its own with Last-Event-ID, so only the rest arrives. For production, authenticate both endpoints (EventSource sends cookies, not custom headers) and swap the in-memory map for resumable-stream and Redis via the consumeSseStream callback, as in the AI SDK's "Chatbot Resume Streams" guide.
Interview angle
If the prompt is "design a chat UI where answers survive a refresh", walk through it in this order:
- Clarify what "survive" means. Reload in the same tab, network drops on mobile, and a second device are different requirements. Confirm that Stop and refresh should behave differently.
- Separate generation from delivery. Say early that the model call must not be tied to the HTTP response, and name the cost: you pay for unread tokens.
- Draw the two endpoints.
POSTcreates the run and returns or stores a stream id;GET /chat/:id/streamsubscribes. AddactiveStreamIdto the chat record and explain the204path. - Solve the no-sticky-sessions problem. The
GETmay land on another instance. Offer pub/sub for hand-off (cheap, but the buffer dies with the producer) or a durable log such as Redis Streams or a workflow engine (survives crashes, costs more). - Design the client state. Server-render saved messages, resume on mount, treat a resume as a full replay keyed by message id, and use
visibilitychangefor mobile. Mention why you avoid merging partial parts. - Handle Stop and races. A dedicated stop endpoint that persists the partial message and only clears
activeStreamIdif it still matches. Clear the old id before starting a new stream. - Close with limits. Function max duration, retention of the buffer, authorising the resume request, and when you would move to a background job with polling instead.
Sources
- AI SDK Docs: Chatbot Resume Streams (read from the
ai7.0.153 package docs, October 2026) - resumable-stream README, v2.2.13 (published September 16, 2026)
- vercel/resumable-stream on GitHub (source of
dist/runtime.jsread from the v2.2.13 npm package) - ai package on npm (v7.0.153 source:
DefaultChatTransport.reconnectToStream,UI_MESSAGE_STREAM_HEADERS) - @ai-sdk/react package on npm (v4.0.157 source:
useChatresume andvisibilitychangehandling) - Next.js Docs: after (last updated September 16, 2026)
- Workflow SDK Docs: Resumable streams (accessed October 2026)
- OpenAI Docs: Background mode (accessed October 2026)
- WHATWG HTML Standard: Server-sent events (accessed October 2026)
- MDN: Using server-sent events (accessed October 2026)
Advertisement