TL;DR Vercel's resumable-stream library covers one scenario: a full page reload while generation is in progress. Tab switches, mobile backgrounding, and device switches all drop the SSE connection with no way to resume. resumable-stream also has a documented incompatibility with stop(). The fix is a session layer where continuity is a property of the channel, not the connection.
resumable-stream ships as part of Vercel's AI SDK infrastructure. It is well-named for what it does: resume a stream after a full page reload. For production applications with more complex continuity requirements, it covers one scenario well and is explicit about the rest - and understanding exactly where it applies is the first step to building streaming that holds up.
What resumable-stream does
resumable-stream is a Redis-backed library that buffers token output server-side. When a client reconnects after a full page reload, it reads from the Redis buffer and replays tokens from where it left off. Generation continues uninterrupted on the server. The client catches up.
This works for the specific case it was designed for: a user reloading the page during a long generation. Vercel published the library specifically to address this scenario. For page reloads, it does what it says.
What resumable-stream doesn't cover
It handles the page-reload case reliably. There are several scenarios it doesn't cover, and the documentation is explicit about them.
Tab switches. When a user switches tabs, the browser may suspend or deprioritize the tab. The SSE connection drops. When the user switches back, there is no resume - the stream is gone. resumable-stream does not handle tab switches. This is explicit in the documentation.
Mobile backgrounding. iOS and Android aggressively terminate background network connections. An AI response in progress when a user switches apps is lost. There is no recovery path.
Device switches. A user who starts a conversation on a laptop and picks up on their phone gets a new session. The in-progress generation is not accessible from the second device. SSE is scoped to a single HTTP request from a single client.
Multiple tabs. A second browser tab opened during an active generation starts an independent session. It has no access to the existing stream.
Network changes. Moving from Wi-Fi to mobile data, or any network transition that drops the connection, loses the stream. There is no reconnection protocol at the SSE level.
These failure modes are common across mobile and web contexts. None of those sessions recover automatically with resumable-stream.
For a practical self-audit of which of these gaps apply to your application, see Vercel AI SDK in production: when DefaultChatTransport needs a session layer.
The abort incompatibility
resumable-stream has a second limitation you'll hit if you're using it: it is not compatible with stop().
When a user cancels a generation, resumable-stream treats the abort as a disconnect and attempts to resume. This creates a conflict: the user stopped intentionally; the library tries to continue. Vercel acknowledged this in GitHub issue #8390: "You are correct. Right now, resume and stop are not compatible."
The issue remains open. If you're using resumable-stream, you have to choose between resumability and reliable cancellation - the library cannot provide both.
Why SSE makes this structural
These aren't gaps that a newer version of resumable-stream can close. They follow from how SSE works.
SSE is an HTTP connection from one client to one server. The connection carries no session identity at the protocol level - it is the session. When the connection drops, the session ends. A new connection is a new session.
The Redis buffer in resumable-stream gives the server a way to store output between connections. But it doesn't give the client a way to reconnect to a specific session. The reconnection depends on the client presenting the right session ID to the right server endpoint. That works on reload, where the page logic handles it. On a tab switch or device change, there is no automatic reconnect mechanism - so it fails.
This is why the maintainer pointed to WebSockets as the real solution in issue #6502. WebSockets are persistent and bidirectional. A client that disconnects can reconnect explicitly, request history from a specific offset, and resume. SSE cannot do this by design.
What session continuity actually requires
The property you need is: session continuity as a feature of the session, not the connection. Any client with the right session identifier - same tab, different tab, different device, after a mobile background - should be able to join and catch up.
This requires three things that resumable-stream doesn't provide:
Offset-based history. Every token needs a position. A reconnecting client presents its last known offset; the server replays from there. Without offsets, the client has to receive everything from the start or nothing.
Session fan-out. Multiple clients subscribing to the same session should all receive the same stream. SSE cannot do this - each client gets its own connection and its own session.
Reconnect semantics. The session layer needs to distinguish a reconnecting client from a new client. On reconnect, it replays missed tokens. On new session, it starts fresh.
A channel-based transport provides all three natively. Session continuity is a property of the channel, not the connection. Any client that connects to the same channel - same tab after a switch, different device, monitoring dashboard - gets current state and replays from the offset it last received.
Vercel's ChatTransport interface in AI SDK 5 is the integration point. A channel-based transport plugs in at this point. useChat hooks, application logic, and UI rendering stay exactly as they are:
const { messages } = useChat({
transport: new ChannelBasedTransport({ sessionId })
});The transport manages reconnection, offset tracking, and fan-out. Your application doesn't need to know which device or tab is connected.
The Redis path vs the Ably path
The Vercel AI SDK stream resumption guide documents a Redis-backed implementation for the page-reload case. It works in production. The consideration is what building it fully involves: three custom endpoints, a Redis dependency, and cancellation logic you implement yourself. On the other side of the comparison: a transport swap and one server-side call.
What the Redis implementation involves
The implementation requires three custom endpoints: POST /api/chat to create and publish the stream to Redis, GET /api/chat/[id]/stream to replay it for reconnecting clients, and POST /api/chat/[id]/stop to handle cancellation. The stop endpoint is the one most implementations skip.
POST /api/chat generates the stream and writes it to Redis. Inside the consumeSseStream callback, you create a stream context using createResumableStreamContext({ waitUntil: after }), assign a unique ID to the stream, and publish it with createNewResumableStream. You save that ID to a database column on the chat record and clear it in onFinish.
async consumeSseStream({ stream }) {
const streamId = generateId();
const ctx = createResumableStreamContext({ waitUntil: after });
// Publish stream to Redis under a stable ID
await ctx.createNewResumableStream(streamId, () => stream);
// Track in your own database
saveChat({ id, activeStreamId: streamId });
},
onFinish: ({ messages }) => {
saveChat({ id, messages, activeStreamId: null }); // clear on completion
}GET /api/chat/[id]/stream replays the stream for a reconnecting client. When useChat mounts with resume: true, it hits this endpoint automatically. The endpoint can return 204 No Content when no active stream exists, so the SDK knows there is nothing to resume. Any other status is treated as an error.
if (chat.activeStreamId == null) {
return new Response(null, { status: 204 }); // not 200, not 404
}
const ctx = createResumableStreamContext({ waitUntil: after });
return new Response(
await ctx.resumeExistingStream(chat.activeStreamId),
{ headers: UI_MESSAGE_STREAM_HEADERS }, // required constant from 'ai'
);POST /api/chat/[id]/stop is a dedicated cancellation endpoint you build from scratch. stop() from useChat closes the HTTP connection, not the generation. Without this endpoint, the server keeps running inference after the client leaves.
The handler requires three idempotency guards. First, check that activeStreamId is not null before acting. Second, confirm the client's activeStreamId matches the server's current one, since a newer stream may have started after the stop was dispatched. Third, re-read the database before clearing the ID to prevent a race condition between the stop and a new generation starting. cancelActiveWork() and markStreamAsStopped() are placeholders in the SDK docs. You implement them against your own backend.
// Guard 1: nothing active
if (chat.activeStreamId == null)
return Response.json({ success: true });
// Guard 2: client sent a stale stream ID
if (body.activeStreamId != null && body.activeStreamId !== activeStreamId)
return Response.json({ success: true });
await markStreamAsStopped(activeStreamId); // your implementation
await cancelActiveWork(activeStreamId); // your implementation
// Guard 3: re-read before clearing to avoid race
const latest = await readChat(id);
if (latest.activeStreamId === activeStreamId)
await saveChat({ id, activeStreamId: null });Alongside the three endpoints, you also need:
A Redis instance. Provisioning, TTL configuration, and monitoring are yours to own.
resumable-streamsets no default stream expiry.A database column for
activeStreamIdon each chat record. You own the reading and writing of it.after()fromnext/server, used as thewaitUntilvalue in the Vercel AI SDK examples. This keeps the Redis write alive after the HTTP response closes. Other runtimes can supply a compatiblewaitUntilfunction, but no alternative examples are documented.
The Ably path
@ably/ai-transport is a custom ChatTransport implementation for the Vercel AI SDK. The ChatTransport interface is how the Vercel AI SDK lets you replace the default HTTP transport. It handles sendMessages and reconnectToStream, and useChat delegates to it. Swapping the transport is a single change to your component:
// Before: default HTTP transport
const { messages, sendMessage, stop } = useChat({ id: chatId });
// After: Ably transport
const { chatTransport } = useChatTransport(); // from <ChatTransportProvider>
const { messages, sendMessage, stop } = useChat({
id: chatId,
transport: chatTransport, // drop-in replacement
});useChat hooks, message rendering, and UI state stay exactly as they are.
The server route has one meaningful change. By default, tokens stream back through the HTTP response. Here, run.pipe() sends them to an Ably channel instead. The HTTP response returns immediately with a run ID, and the client receives tokens through the channel from that point.
const run = session.createRun(invocation, { signal: req.signal });
after(async () => {
await run.start();
await run.loadConversation(); // rebuild conversation from channel history
const result = streamText({
model,
messages: await convertToModelMessages(run.messages),
abortSignal: run.abortSignal, // fires if the client calls stop()
});
// Routes to Ably channel, not the HTTP response
const pipeResult = await run.pipe(result.toUIMessageStream());
const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
await run.end(outcome);
});
return Response.json({ runId: run.runId }); // returns immediatelyThe reconnection and recovery layer handles all of this without any code on your side. For a production walkthrough of stream resumption alongside barge-in and human handover, see AI agent streaming in action . Brief disconnects replay missed messages automatically on reconnect. Longer outages fall back to channel history. Server-side append failures recover silently inside run.pipe(). stop() from useChat cancels the run directly, with no separate endpoint needed.
No GET resume endpoint. No stop endpoint. No Redis instance. No activeStreamId column. Only one dependency is shared with the Redis path: after() from next/server, which keeps the serverless function running after the HTTP response closes.
For the full integration guide, see the Ably AI Transport docs for the Vercel AI SDK. The server sample above covers the stream resumption case. For human-in-the-loop patterns, the guide covers the suspend path alongside it.
The complexity delta in Redis vs Ably
The Redis implementation is complete and production-deployable. But as this comparison shows, the engineering surface area leaves a lot for you to build and maintain. Three custom endpoints, three idempotency guards in the stop handler, a Redis instance to provision and monitor, a database column to manage, and cancellation logic to implement against your own backend. The Ably path replaces the endpoint trio and the Redis dependency with a transport swap and one server-side call. The operational responsibilities that fall away are not incidental: they are what you would otherwise need to build correctly and maintain over time.
For the build-vs-buy framing at the product level - the four production conditions where Redis-backed resumption compounds infrastructure burden, and the conversations where durable sessions earn their place - see AI chat stream resumption: when Redis is enough, and when you need durable sessions.
What to look for in a transport
When evaluating transports for session continuity, the relevant capabilities:
Offset-based replay. The transport should track message positions and support catch-up from a specific offset on reconnect - not full replay from the start, which re-triggers the agent.
Multi-device fan-out. One session, multiple subscribers. Connected clients get live streaming; reconnecting clients get automatic catch-up.
Abort compatibility. The transport should support explicit cancel signals as typed messages, separate from connection close. This is the incompatibility resumable-stream cannot resolve.
Ably AI Transport integrates with the Vercel AI SDK to add durable sessions, multi-device sync, and bidirectional control to your chat application. Visit the Ably AI Transport overview, read the documentation, or sign up free
Ready to build? Get started with Vercel AI SDK.
Frequently asked questions
Does resumable-stream handle tab switches and mobile backgrounding?
No. resumable-stream handles one scenario: a full page reload during an active generation. When a user switches tabs, the browser may suspend the tab and the SSE connection drops with no mechanism to resume it. The same applies to mobile backgrounding. iOS and Android aggressively terminate background connections. Both limitations are explicit in Vercel's documentation.
Can I use resumable-stream outside of Next.js?
resumable-stream requires a waitUntil function to keep the serverless runtime alive after the HTTP response closes. The Vercel AI SDK documentation passes after() from next/server to fill this role. Other runtimes can supply their own equivalent - Cloudflare Workers provides ctx.waitUntil, for example - but the Vercel AI SDK documentation only shows the Next.js path. If you are not using Next.js, there is no documented example to follow.
How do I cancel an AI generation when using resumable-stream?
You need to build a dedicated POST stop endpoint. Calling stop() from useChat only closes the HTTP connection: the server continues running inference until it finishes naturally. The stop endpoint requires three idempotency guards to handle race conditions between a cancellation and a new generation starting. Vercel acknowledged this incompatibility in GitHub issue #8390. The issue remains open.
How do I deliver the same AI stream to multiple devices or tabs?
SSE is scoped to a single HTTP request from one client. Each tab or device starts its own connection and its own session, with no built-in fan-out. A channel-based transport solves this: session continuity is a property of the channel rather than the connection, so any client with the right session identifier can subscribe and receive the stream, whether on the same device or a different one.
Research basis: analysis of 300+ GitHub issues in vercel/ai repository; 31 Vercel Community Forum threads (65% unresolved); 35 Stack Overflow questions (40% unanswered). GitHub issues cited: #11865 (tab switch, stream resumption), #8390 (resume and stop incompatible - acknowledged, unresolved), #6502 (resumable stream can't be stopped), #8477 (resume + onFinish crashes), #6974, #11512 (Expo/React Native). Maintainer quote from Lars Grammel, Vercel. Vercel acknowledgment from Nico Albanese, Vercel (#8390).
Recommended Articles
Why Vercel AI SDK stop() doesn't cancel the stream
stop() in Vercel AI SDK closes the connection, not the generation. The abort signal is a separate HTTP request and may never reach the generating process.
WebSockets on Vercel: why serverless functions can't host them
Vercel serverless functions can't host WebSocket connections, even with Fluid Compute. Options and how to connect a WebSocket provider to Vercel AI SDK.
Why AI chat history disappears between sessions
Vercel AI SDK stores messages in component state. When the page reloads or the user returns later, the history is gone. How to add durable persistence to AI applications.