Search IOCombats

Search challenges, guides, questions and articles

How AI Chat Apps Survive a Page Refresh: Resumable Streams, Redis Pub/Sub and Replay
Case Study · AIOct 29, 202618 min read

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

aicase studysystem designstreamingresumable streamsvercel ai sdk

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:

  1. You send a message. Text starts streaming in, same as any chat app.
  2. You refresh, close the tab, or lose the network for a few seconds.
  3. You come back. The page loads the saved conversation, and the half-written answer reappears and keeps growing.
  4. If you open the same chat in a second tab, both tabs show the answer streaming at the same time.
  5. 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

Diagram
100%
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" --> UC
visualized byIOCombats

The walkthrough:

  1. Browser. useChat sends the new message with a POST. With resume enabled, it also sends a GET to /api/chat/<id>/stream every time the chat component mounts.
  2. Producer (POST handler). Calls streamText, converts the result to the AI SDK's UI message stream, and hands that SSE stream to resumable-stream through the consumeSseStream callback. This instance is now the producer: it owns the model call and keeps every chunk it has produced.
  3. 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.
  4. Redis. Not a message log. It holds one small key per stream (the sentinel) and carries pub/sub messages between producer and consumers.
  5. 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 waitUntil function, and in Next.js you pass after from next/server. Next.js documents that after runs for the route's default or configured max duration, which means your longest answer has to fit inside maxDuration.

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:

Diagram
100%
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 null
visualized byIOCombats

Step by step:

  1. Producer starts. It subscribes to request:<streamId> first, then sets sentinel:<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.
  2. Consumer arrives. It runs INCR on the sentinel. If the result is greater than 1, a producer exists, so the consumer joins. If the value is "DONE", INCR fails 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?".
  3. Handshake. The consumer creates a random listenerId, subscribes to chunk:<listenerId>, then publishes { listenerId, skipCharacters } on the request channel.
  4. 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.
  5. 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 useChat registration for a chat mounts, and the chat is not already submitted or streaming, it calls chat.resumeStream(). The transport's reconnectToStream sends the GET. A 204 resolves to null, and the status goes back to ready.
  • It registers a visibilitychange listener. 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

Diagram
100%
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 ready
visualized byIOCombats
  1. The POST handler saves the user message and clears any previous activeStreamId, so an old stream cannot be resumed by mistake.
  2. It starts streamText, wraps the SSE output with createNewResumableStream, and saves activeStreamId = s1.
  3. The user refreshes. The original response closes; the producer keeps reading tokens into its buffer.
  4. The new page server-renders the saved messages and mounts useChat with resume: true, which sends the GET.
  5. Instance B reads activeStreamId and calls resumeExistingStream(s1): INCR, subscribe, publish.
  6. Instance A answers with the full buffer, then forwards each new chunk.
  7. The browser sees a start chunk, resets the assistant message, rebuilds it from the replay, and keeps appending.
  8. When the model finishes, onEnd saves the final messages and clears activeStreamId. Later loads get a 204 and read from the database.

Trade-offs

DecisionWhat it gives upWhy it is still chosen
Keep generating after disconnectYou pay for tokens nobody may ever readLong answers are expensive to regenerate, and users expect them to survive a refresh
Buffer chunks in producer memoryA crashed producer loses the stream; there is nothing to replayNear-zero Redis cost and latency when nobody reconnects, which is the common case
Redis pub/sub for hand-offMessages are fire-and-forget; needs an ack timeout and a watchdogWorks across serverless instances without sticky sessions
Full replay on resumeRe-sends text the user already sawSimple and always correct; avoids merge bugs with half-built message parts
activeStreamId in your databaseOne more field to keep consistent, plus race handlingThe resume endpoint needs a cheap lookup from chat to stream
Separate stop endpointMore API surface than calling abort()Lets a refresh and a deliberate Stop mean different things
Producer lifetime tied to waitUntil / afterAnswers longer than the function's max duration get cut offNo 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).
  • useChat with resume sends a GET to /api/chat/[id]/stream on mount, and 204 means no active stream (AI SDK docs and ai 7.x source).
  • useChat reconnects on visibilitychange only after a network-disconnect interruption (@ai-sdk/react source and docs).
  • Resumed streams are full replays by default, reset on the first start chunk; resumeStreamIsReplay: false changes that (AI SDK docs).
  • resumable-stream is designed for serverless without sticky load balancing, and does one INCR and SUBSCRIBE per 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-stream v2.2.13 source).
  • The chunk buffer is an in-memory array in the producer process (resumable-stream v2.2.13 source).
  • Next.js after runs within the route's max duration; OpenAI background mode resumes with starting_after and 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-stream version 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:

  1. 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.
  2. 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.
  3. Draw the two endpoints. POST creates the run and returns or stores a stream id; GET /chat/:id/stream subscribes. Add activeStreamId to the chat record and explain the 204 path.
  4. Solve the no-sticky-sessions problem. The GET may 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).
  5. Design the client state. Server-render saved messages, resume on mount, treat a resume as a full replay keyed by message id, and use visibilitychange for mobile. Mention why you avoid merging partial parts.
  6. Handle Stop and races. A dedicated stop endpoint that persists the partial message and only clears activeStreamId if it still matches. Clear the old id before starting a new stream.
  7. 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

Advertisement