Why Web Streams Matter
Browser-based JavaScript has technically been able to consume streaming data since the days of XMLHttpRequest, but the Fetch API only made it a first-class part of the platform in 2015. Today, the Web Streams API is a standardized mechanism for continuously sending and receiving data asynchronously across network connections, and it is supported across web browsers, Node.js, and Deno.
The core value proposition is twofold. First, data arrives in chunks and can be processed immediately, without waiting for the full payload. This improves perceived performance for large data transfers, especially over slow connections. Second, web streams give developers fine-grained control over how data is read, transformed, and written, which makes it possible to build complex data pipelines.
Three Stream Types
All web streams fall into one of three categories:
ReadableStream— data can be asynchronously read from it through a reader, but not written to it.WritableStream— data can be written to it through a writer, but not read from it.TransformStream— data is manipulated as it passes through, accepting input, transforming it, and outputting the result.
These stream types can be chained together by piping. A ReadableStream can feed a TransformStream via pipeThrough(), and the output can be directed to a WritableStream via pipeTo(). This compositional model keeps pipelines readable and maintainable.
Chunks: The Unit of Transfer
Chunks are the fundamental data units in any web stream. They are typically either strings (for text data) or Uint8Array objects (for binary data), but their size is rarely predictable. Several factors influence chunk dimensions:
- Data source — reading from a file may produce chunks matching the OS block size.
- Stream implementation — the stream may buffer data to emit larger chunks, or emit small chunks as soon as data is available.
- Local development environment — without an actual network hop, the stream implementation is the dominant factor.
- Network — the Maximum Transmission Unit (MTU) can cap chunk size, and physical distance can cause fragmentation.
Because chunk size is unpredictable, code should be written to handle chunks of any dimension. A minimal example demonstrates the pattern: encode a string into a ReadableStream, pipe it through a TransformStream that decodes, uppercases, and re-encodes the text, and finally pipe to a WritableStream that logs the output.
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const readableStream = new ReadableStream({
start(controller) {
const text = "Stream me!";
controller.enqueue(encoder.encode(text));
controller.close();
},
});
const transformStream = new TransformStream({
transform(chunk, controller) {
const text = decoder.decode(chunk);
controller.enqueue(encoder.encode(text.toUpperCase()));
},
});
const writableStream = new WritableStream({
write(chunk) {
console.log(decoder.decode(chunk));
},
});
readableStream
.pipeThrough(transformStream)
.pipeTo(writableStream); // STREAM ME!
For HTTP responses, the Fetch API exposes streamed bodies through getReader() on response.body. This allows sequential reading of chunks as they arrive, which is the primary method for consuming streamed network data.
const decoder = new TextDecoder();
const response = await fetch('/api/stream');
const reader = response.body.getReader();
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const data = JSON.parse(decoder.decode(value));
// Do something with data
}
Backpressure and Flow Control
Backpressure occurs when a data producer generates chunks faster than a consumer can process them. Without intervention, excess data is queued, the queue grows, and memory can eventually overflow. The naive solution — halting production until everything is consumed — is wasteful, since it leaves the producer idle even when processing capacity remains.
Web streams solve this with flow control. While a stream is in the "readable" state, data moves freely from producer to consumer. If consumption lags, the stream transitions to a "backpressure" state, signaling the producer to pause. When the consumer catches up, the stream returns to "readable" and production resumes. This automatic pause-and-resume mechanism prevents both memory overflow and producer idle time.
The mechanics differ slightly between stream types. A ReadableStream applies backpressure indirectly: the stream infers that the reader is busy when it does not call the read() method, and holds off on sending more data. A WritableStream exposes backpressure directly — its write() method returns a promise that resolves only when the stream is ready for more data.
const stream = new WritableStream(...)
async function writeData(data) {
const writer = stream.getWriter();
for (const chunk of data) {
// Wait for the ready promise to resolve before writing the next chunk
await writer.ready;
writer.write(chunk);
}
writer.close();
}
An accumulation of unresolved promises from write() is an immediate signal of backpressure. Using await on each write call ensures that production never outpaces what the consumer can accept.
Server-Sent Events vs. Streams
While web streams are a general-purpose data processing tool that closes the HTTP connection when transmission completes, Server-Sent Events (SSE) serve a different purpose: they keep a long-lived HTTP connection open so the server can push new events whenever they are available. SSE is widely used for real-time updates, including by AI providers such as OpenAI.
SSE responses arrive as plain text in fragmented chunks. The eventsource-parser library provides a feed function that handles the parsing of these fragments into discrete events.
import { createParser } from "eventsource-parser";
export function OpenAITextStream(
res: Response,
): ReadableStream {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let counter = 0
const stream = new ReadableStream({
async start(controller): Promise<void> {
function onParse(event: ParsedEvent | ReconnectInterval): void {
if (event.type === 'event') {
const data = event.data
if (data === '[DONE]') {
controller.close()
return
}
try {
const json = JSON.parse(data)
const text =
json.choices[0]?.delta?.content ?? json.choices[0]?.text ?? ''
if (counter < 2 && (text.match(/\n/) || []).length) {
return
}
const queue = encoder.encode(`${JSON.stringify(text)}\n`)
controller.enqueue(queue)
counter++
} catch (e) {
controller.error(e)
}
}
}
const parser = createParser(onParse)
// [Asynchronously iterate](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) the response's body
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk))
}
}
})
return stream
}
Streaming on Vercel
Both Edge and Serverless environments on Vercel support web streams, which enables two important patterns: progressively rendering UI components as data becomes available and streaming JSON payloads incrementally. For users on slow connections, this progressive delivery significantly improves perceived performance.
Streaming LLM Responses
As AI-generated text becomes more common, whether to stream responses is a practical engineering decision. If a streamed response is long, chunked delivery drastically improves perceived responsiveness, but the tooling for managing such streams is more complex than for a simple blocking UI. Model choice matters too: larger models take longer to generate tokens, making streaming more valuable, while smaller, faster models may not need it.
The Vercel AI SDK is designed to reduce the boilerplate around streamed LLM responses. A route handler returns a streaming response from the model provider, which is then consumed by the SDK's useChat and useCompletion hooks to build the UI.



