Library / SDK
vercel/resumable-stream avatar
vercel/resumable-stream

resumable-stream makes a serverless SSE stream survivable, at the cost of Redis

Stream resumption for web streams

581 stars45 forksTypeScriptMIT

At a glance

What is it?
resumable-stream is a TypeScript library that wraps a stream of strings so a client can reconnect after losing the connection or a second client can follow along, using Redis pubsub with no sticky sessions required. It is designed to add nothing to the common case where nobody reconnects.
Who is it for?
Adopt resumable-stream if you serve server sent events from a serverless deployment where a client dropping the connection loses the whole response, and you already run Redis. Do not adopt it if your streams are short lived, if you cannot accept a Redis dependency, or if your platform gives you sticky sessions for free, since the library exists to remove that requirement.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 36 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem: a dropped SSE connection loses the whole response

resumable-stream is a library for wrapping streams of strings, the README gives SSE web responses as the example, so that a client can resume them after losing a connection, or so that a second client can follow along.

The target environment is stated in one line: designed for use in serverless environments without sticky load balancing. That constraint explains the whole design. In a serverless deployment, consecutive requests from the same user can land on different function instances, and the connection that was streaming your response does not exist on the instance that receives the reconnect. Without session affinity, a naive resume attempt has nowhere to resume from.

The answer here is a pubsub mechanism, designed to be used with Redis.

So the use case is narrow and specific. It is not a general stream library and not a queue. It is the piece that makes a long lived server sent event response survive a client that reconnects, on infrastructure that will not keep the connection's state in memory across invocations.

If your streams are short, or your platform gives you sticky sessions, or your users are on a connection that does not drop, none of this applies to you. The README's phrase for the audience is web streams, and the example is an SSE response, and everything in the design follows from that.

The common case costs one INCR and one SUBSCRIBE

The most important sentence in the README is about cost, not capability.

The library was designed to minimize latency impact and Redis usage for the common case that stream recovery is not needed, and in that common case the library performs a single INCR and SUBSCRIBE per stream.

That is a remarkable constraint to design against. The obvious implementation of resumable streams buffers everything server side so a reconnecting client can be replayed from an offset, and that means writing every chunk to storage, which is latency, memory and cost on every request whether or not anyone reconnects.

This design pushes the buffering into the producer's memory instead, and uses Redis only for the coordination signal. One INCR to track the stream's progress, one SUBSCRIBE to listen for additional consumers. If a second client never arrives, that is the entire cost.

The trade is that the buffer lives in the memory of the instance that is producing the stream, which is precisely the thing that does not survive in a serverless environment. So the guarantee is bounded by the life of the producer process, and the design accepts that. The alternative, always writing to durable storage, would cost every user for the benefit of the few who reconnect.

For an engineer evaluating this, the question to ask is what your reconnect rate actually is. If it is near zero, this library costs almost nothing. If it is high, the memory buffer is the wrong place to keep the data and you want durable storage instead.

How the producer and the second consumer actually work

The How it works section of the README describes the flow in seven steps, and the roles are worth following closely because they explain what your code has to provide.

The first time a resumable stream is invoked for a given streamId, a standard stream is created, and this is now the producer. The producer will always complete the stream, even if the reader of the original stream goes away. Additionally, the producer starts listening on the pubsub for additional consumers.

When a second resumable stream is invoked for a given streamId, it publishes a message to alert the producer that it would like to receive the stream. The second consumer now expects messages of stream content via the pubsub. The producer receives the request, and starts publishing the buffered messages and then publishes additional chunks of the stream.

So there are two roles, and they are not symmetric. The producer is the one that owns the original stream and the buffer. The second consumer is a follower that receives the content over pubsub rather than over its own HTTP connection.

Two consequences follow. The producer must outlive the original reader, which is what the waitUntil callback in the examples is for, since it lets the platform keep the invocation alive after the response is sent. And the second consumer does not get its own execution of the stream, it gets a copy of the producer's output, so a follow along client sees exactly what the first client saw.

One call for the common case, two calls when you want explicit control

The idempotent API is the one most people will use, and it is a single call.

typescript
const streamContext = createResumableStreamContext({
  waitUntil: after,
  // Optionally pass in your own Redis publisher and subscriber
});

export async function GET(req: NextRequest, { params }: { params: Promise<{ streamId: string }> }) {
  const { streamId } = await params;
  const resumeAt = req.nextUrl.searchParams.get("resumeAt");
  const stream = await streamContext.resumableStream(
    streamId,
    makeTestStream,
    resumeAt ? parseInt(resumeAt) : undefined
  );
  if (!stream) {
    return new Response("Stream is already done", {
      status: 422,
    });
  }
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
    },
  });
}

The resumableStream method takes the stream id, the function that produces the content, and an optional resume offset, and it returns the stream or null if the stream is already done, in which case you return a 422.

The resume offset comes from a query parameter, resumeAt, parsed as an integer, so the client tells the server where to pick up. The producer function is passed in rather than being created inside the library, which keeps the library agnostic about where your content comes from.

The explicit variant splits the two operations into createNewResumableStream and resumeExistingStream, used from a POST for the first and a GET for the second. That is the shape to use when you want the create and the resume to be different endpoints, for example when the client posts a request that starts a job and then follows the result on a separate GET.

The waitUntil property is what ties the library to the Next.js after function, and the comment in the examples says you can optionally pass in your own Redis publisher and subscriber.

Three entry points: redis, ioredis, and your own client

The package exports four paths, and the choice is about which Redis client you already have.

The main entry point uses the redis client. The ioredis entry point, imported as resumable-stream/ioredis, changes the default Redis client to ioredis. And the generic entry point, resumable-stream/generic, is for a different Redis-compatible client such as Upstash Redis or Valkey.

The generic path is the most interesting because it defines the interface the library actually needs, and it is small.

typescript
import { createResumableStreamContext } from "resumable-stream/generic";
import type { Publisher, Subscriber } from "resumable-stream/generic";

// Example: Create adapters for your Redis client
const publisher: Publisher = {
  connect: async () => { /* ... */ },
  publish: async (channel, message) => { /* ... */ },
  set: async (key, value, options) => { /* ... */ },
  get: async (key) => { /* ... */ },
  incr: async (key) => { /* ... */ },
};

The Publisher interface needs connect, publish, set, get and incr. The Subscriber interface needs connect, subscribe and unsubscribe, with a callback for the subscribe call.

The README is emphatic about one thing here: when using the generic interface, both publisher and subscriber are required, and the library will throw an error if they are not provided. That is a useful design choice, because the alternative would be a runtime failure later when the first publish happens.

The set and get on the publisher are what the library uses to store the stream state, and incr is the counter the README describes as the single cost in the common case. The .env.example file in the repository shows the expected connection string shape, a REDIS_URL pointing at localhost with a default user and password.

Where the two licence records disagree, and what that means

There is an inconsistency in this repository worth raising before you copy the licence into your own project.

The repository licence is recorded as MIT, and the LICENSE file in the repository is the MIT text. But the package.json in the repository lists the package licence as ISC, and it also carries an author field that is an empty string.

Those are two different permissive licences. The practical difference between MIT and ISC is small, mostly around the patent grant, which MIT includes explicitly and ISC does not. For most commercial uses either is workable. But the point of a licence field is to let a downstream user know the terms without reading the file, and here the two records disagree, so you have to open the LICENSE file to find out which one applies to the distributed package.

This is the kind of detail that is worth resolving upstream with a pull request, and it is a good example of why reading the LICENSE file matters even when the metadata looks settled.

The rest of the packaging is tidy. The package publishes only dist, the version is 2.2.13, the package manager is pinned with pnpm at a specific version and hash, and the build script compiles with tsc against tsconfig.build.json and then runs typedoc to produce the type docs. The prepublishOnly script is a guard rail, it builds and then checks that the dist directory exists and is not empty, failing with an error message if it is. The test script runs vitest with DEBUG enabled.

Maintenance, and the parts of the design the README leaves open

The repository is not archived and the last push was on 2026-09-16. There are no GitHub releases, and the homepage field points at sdk.vercel.ai, so this library ships as part of Vercel's AI SDK surface rather than as a standalone product with its own release notes.

Two things in the documentation are worth flagging for anyone relying on them.

The first is that the buffer lives in the producer's memory. The README says the producer will always complete the stream even if the reader goes away, and that the producer starts publishing the buffered messages when a second consumer appears. It does not say how long the buffer is retained, or what happens to a reconnect after the producer has finished and been reclaimed. If your clients reconnect after a long delay, that is the behaviour you need to check.

The second is that this is a resumption mechanism for streams, not a durable event log. The stream id is the key, and the resume offset is a number the client sends back. There is no replay of a completed stream, because the library returns null and you answer 422. That is a sensible boundary, and it is also the boundary to design around: the client has to hold the last offset it saw, and the server has to accept it, and the two have to agree.

The type docs are generated into a docs/ directory by typedoc, and there is a typedoc.json and a typedoc-plugin-markdown in the dev dependencies, so the API reference is produced from the source rather than written by hand.

Editorial conclusion

Adopt resumable-stream if you serve server sent events from a serverless deployment where a client dropping the connection loses the whole response, and you already run Redis. Do not adopt it if your streams are short lived, if you cannot accept a Redis dependency, or if your platform gives you sticky sessions for free, since the library exists to remove that requirement. Before you wire it in, read the How it works section in the README for the producer and consumer roles, decide between the redis, ioredis and generic entry points, and note that the generic interface requires you to supply both a Publisher and a Subscriber.

Frequently asked questions

What is resumable-stream for?

It wraps streams of strings, such as SSE web responses, so a client can resume them after losing a connection, or so a second client can follow along. It is designed for serverless environments without sticky load balancing.

Does resumable-stream require Redis?

The library relies on a pubsub mechanism and is designed to be used with Redis. In the common case where stream recovery is not needed, the library performs a single INCR and SUBSCRIBE per stream.

Can I use resumable-stream with a Redis-compatible client?

Yes. You can import from resumable-stream/generic and provide your own Publisher and Subscriber implementations, which the README says are both required, and the library throws an error if they are missing. There is also a dedicated entry point for ioredis.

How does the producer and consumer split work in resumable-stream?

The first invocation for a streamId creates the standard stream, which becomes the producer, and the producer always completes the stream even if the original reader goes away. A second invocation publishes a message so the producer starts publishing the buffered messages and then additional chunks over the pubsub.

Official sources

  1. Issues
  2. License: MIT
  3. Project website
  4. README
  5. vercel/resumable-stream on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/vercel-resumable-stream.svg)](https://hysenlabs.com/projects/vercel-resumable-stream)

Was this useful?