Two tabs, one run
The moment a run outlives the connection that started it, the stream stops being a response and becomes a resource with an id — which is what makes a second viewer, a reconnect mid-flight, and a refresh the same problem with one answer.
The user asks for award availability from Chicago, waits forty seconds, gets bored, and opens the same chat in a second tab. Or their laptop sleeps and the connection drops. Or they hit refresh because nothing has moved in twenty seconds.
Three different stories. If your mental model is “request”/“response,” they are three different bugs and you will fix them three times, badly. They are one bug, and it has one fix.
What the vendor architecture actually requires
Vercel’s resume-streams documentation is the most complete published account of this, and it is a vendor documenting its own SDK alongside the infrastructure it prefers you rent. Take the architecture seriously and the brand names lightly.
The premise it states, and the sentence this whole module keeps returning to:
“Client-side aborts are treated as disconnects. Closing a tab, refreshing the page, or calling
stop()only closes the current HTTP connection and should not cancel the underlying generation.”
Read that as an architectural fact rather than a warning. The generation already outlives the connection. You are not building persistence to make that true; it is true by default, and everything you build is about being able to find the thing again.
The parts the page says you need are worth listing exactly, because the shape of the list is the lesson: Redis, a persistence layer tracking an activeStreamId per chat, two endpoints, and the resumable-stream package. Three of those four are your code and your infrastructure. The SDK contributes a client function and a package.
Redis is the vendor’s choice, not a requirement of the problem. Nothing in the mechanism needs that specific store — it needs a durable, queryable place to put stream output, keyed by an id, that outlives one process and one connection. Say it that way in a design review and you keep the argument about the requirement instead of about a dependency.
The client half is one function on the chat hook:
// Server: persist a stream id the moment generation starts,
// independent of any one HTTP connection.
// Client:
const { resumeStream } = useChat()
useEffect(() => {
resumeStream() // reattaches to an in-flight generation, if any
}, [])Four lines on the client, and every hard part is on the other side of them. That ratio is why this lesson lives in the production module and not in a UI one.
One id, three stories
Once the run has an id and its output lands somewhere durable, look at what the three stories become.
A refresh is a new client asking “what is the active run for this chat, and what has it produced?” A reconnect after a dropped connection is the same question, asked without the user doing anything. A second tab is the same question again, asked by a client that never had the original connection at all. Vercel does not build these as three features, and neither should you: the activeStreamId lookup answers all three.
The cursor already exists on the wire
Before you build a resume endpoint, notice that the reattachment cursor is a decades-old part of the SSE format, and that you probably threw it away.
WHATWG HTML specifies that an id: field sets the event source’s last event ID, and that on reconnection the browser sets Last-Event-ID in the request header list. The browser reconnects on its own and tells your server where it got to. That is the entire client contribution to resumability, and it is free.
Two details keep people from relying on it correctly. The header is specified only there — MDN has no page for Last-Event-ID at all, and the URL you would guess returns a 404, which is why so much writing about SSE resumption is vague. And the reconnection interval is not a number you may state: the specification calls it implementation-defined, “probably in the region of a few seconds.” The widely-repeated three seconds is not in the spec. Set retry: yourself if the interval matters to you.
Note also what most agent chat interfaces have given up. Because EventSource cannot POST a conversation — the transport lesson works through why — the common shape here is a POST read with fetch, which keeps the SSE wire format and loses the automatic reconnection and Last-Event-ID handling that came with it. If that is your setup, reconnection is your code now, and the id field is still the right cursor to write.
What an id costs you
Giving a run an identity is not free, and the costs are policy questions, not code. Each one has to be answered on purpose, because the default answer is bad in a specific way.
- Who may attach to a run id. A resume endpoint that takes an id and returns generated output is an endpoint that returns somebody else’s conversation if the id leaks or is guessable. Authorise the attach the way you would authorise reading the chat, because that is what it is.
- How long output is retained. The store now holds model output keyed by chat. That is a retention decision, a privacy decision, and a bill. “Forever, because nobody set a TTL” is the answer you get by not choosing.
- What a late joiner sees. The tab that attaches at token 400 either replays from the start or joins live. Both are defensible; only one is what your UI currently assumes.
- When the run is over. Something has to clear
activeStreamId, including when the process generating it died without saying so. A chat that believes a run is active forever shows a spinner forever.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Your chat stores an activeStreamId per conversation. The process generating a run is killed mid-flight and nothing clears the id. Describe what the user sees, and why it is worse than an error.
Check your answer
Every client that opens that chat asks “is there an active run?”, is told yes, attaches to a stream that will never produce another byte, and renders the streaming state. Forever. A refresh does not help, because the refresh is what performs the lookup that lies to it.
It is worse than an error because it is unfalsifiable from the client’s side. A dead connection eventually times out somewhere and the UI can say so. A live connection to a durable record of a run that stopped existing looks exactly like a slow model, and the honest symptom — the text stopped — is the same symptom as four other failures. That is the next lesson.
The fix is that the run’s completion has to be recorded by something that outlives the generating process: a terminal event written into the store, a heartbeat with an expiry, or both. Clearing the id in a finally block only handles the deaths your code got to observe.
Hands on
Give the run an id and prove all three stories are one
Done when: The flight chatbot survives a mid-response refresh, a killed connection and a second tab open on the same chat, all through one attach path — and ARTIFACT.md records the four policy answers: who may attach, how long output is retained, what a late joiner sees, and what clears the active run.
- Give a run an id at the moment generation starts, before the first byte goes anywhere, and persist it against the conversation. Write the id into the response too, so a client that has the connection and a client that does not are asking about the same thing.
- Write generated output to a durable store as it is produced, keyed by that id. Use whatever store you already run. If the honest answer is that you run none, say so in
ARTIFACT.md— that is the infrastructure bill this lesson exists to make visible, and it is a legitimate reason to decide against resumability on purpose. - Build one attach path, not three: given a chat, return the active run id if there is one, plus what it has produced. Then wire the refresh case to it and confirm a mid-response reload picks the answer back up.
- Now test the other two stories against the same path without writing new code. Kill the connection at the network level and confirm a reconnect reattaches. Open the chat in a second tab mid-run and record exactly what the second tab shows — replay from the start, live from now, or nothing. If it is nothing, that is a result, and it goes in the file.
- Kill the generating process mid-run, deliberately, and confirm the chat does not sit in a streaming state forever. If it does, add the expiry or the terminal record that fixes it, and note in the running log what you added.
- Answer the four policy questions in
ARTIFACT.mdin one sentence each. Bring them into the chat. I will push hardest on retention, because it is the one with a cost that arrives later and a default that nobody chose.
What this does not cover
The refresh-survival lesson in the control module covers the same event from the user’s side: what the interface should do while reattaching, and how a resumed answer differs from a fresh one on screen. This lesson deliberately stays on the run’s identity and what having one costs, because the interaction is only defensible once the resource exists.
Nothing here is about the cost of holding a long conversation in a browser. Virtualising a long transcript, keeping the stream out of React state, and measuring what any of it costs belong to Front-end performance under streaming load. A second viewer changes your architecture; it does not change your render budget, and that course owns the budget.
The lookup in this lesson can also lie, and the way it lies is the subject of the next one. A run that is recorded as active but has stopped producing looks identical to a slow model, a dropped transport, a failed tool and a refusal — four failures, one symptom, and a 200 that was sent before any of them happened. That is the error taxonomy.
Read this next — primary source
Chatbot Resume StreamsVercel — vendor documenting its own product (AI SDK), and prescribing its own hosting-adjacent stack. Re-verified 2026-09-05
This lesson takes its central quote and its architecture from this page. Read it in full for the part a summary flattens: the exact list of moving parts the vendor says you need — a durable store, a persistence layer tracking an active stream id per chat, two endpoints and a separate package — and notice how much of it is your code rather than theirs. Read it also as an argument you are allowed to disagree with. The store is Vercel’s choice, not a requirement, and the worked example is one reader reattaching, not two watching at once.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.