Streaming Data from the Server

with the Fetch API and ReadableStream

Photo by Libby Penner on Unsplash

When you call fetch() and await the response, most developers immediately reach for .json() or .text() and move on. That works perfectly for small payloads. But what happens when the server starts sending a large dataset, a live log feed, or a progressively generated AI response? Waiting for the entire payload before doing anything with it creates a frustrating user experience and wastes time.

The Fetch API, combined with the Streams API’s ReadableStream, gives you a lower-level handle on the response body as it arrives. Instead of buffering everything and handing you a completed blob of data, you can read it chunk by chunk processing, displaying, or transforming data in real time as it streams in.

In this article you will learn how ReadableStream works, how to consume a streamed response with fetch(), how to decode and process chunks incrementally, and how to abort a stream mid-flight. All examples use native browser APIs with zero dependencies.

Understanding ReadableStream and the Response Body

Every Response object returned by fetch() exposes a body property. That property is a ReadableStream not a string, not an array, but a lazy, pull-based source of data that you consume through a reader.

A ReadableStream produces data in chunks (typically Uint8Array byte arrays for network responses). You obtain a locked reader by calling body.getReader(), and then you repeatedly call reader.read() to pull the next chunk. Each call to read() resolves to a plain object with two properties: done (a boolean that is true when the stream is exhausted) and value (the current Uint8Array chunk, or undefined when done).


const response = await fetch('https://httpbin.org/stream-bytes/1024');
const reader = response.body.getReader();

let totalBytes = 0;

while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
console.log(`Received chunk: ${value.byteLength} bytes (total so far: ${totalBytes})`);
}

console.log(`Stream complete. Total bytes received: ${totalBytes}`);

Expected output:

Received chunk: 512 bytes (total so far: 512)
Received chunk: 512 bytes (total so far: 1024)
Stream complete. Total bytes received: 1024

The key insight here is that your code runs inside the loop while the download is still in progress. You are not waiting for the full response; you are reacting to each chunk as the network delivers it.

Decoding Text Streams Incrementally

Network chunks arrive as raw bytes (Uint8Array). To work with text — for example, a streaming JSON-lines feed or a server-sent log — you need to decode those bytes into strings. The TextDecoder API handles this, and it is stream-aware: it correctly handles multi-byte UTF-8 characters that may be split across two consecutive chunks.

The pattern below simulates a realistic use case: rendering text to the page as it streams in, rather than waiting for the full response.

async function streamTextToPage(url, outputElement) {
const response = await fetch(url);

if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}

const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');

while (true) {
const { done, value } = await reader.read();

if (done) {
console.log('Stream finished.');
break;
}

// `stream: true` tells the decoder to hold partial characters
// across chunk boundaries rather than replacing them with U+FFFD
const text = decoder.decode(value, { stream: true });
outputElement.textContent += text;
}

// Flush any remaining bytes held by the decoder
outputElement.textContent += decoder.decode();
}

const output = document.getElementById('output');
streamTextToPage('https://httpbin.org/drip?duration=3&numbytes=200&code=200', output);

Expected output:

(The #output element on the page fills in progressively over ~3 seconds,
displaying text as each chunk arrives, rather than appearing all at once.)

Console: Stream finished.

Passing { stream: true } to decoder.decode() is critical. Without it, any multi-byte UTF-8 sequence split between two chunks would be corrupted — the decoder would emit the replacement character (U+FFFD) instead of waiting for the remaining bytes.

Processing Newline-Delimited JSON (NDJSON) Streams

A common real-world pattern is Newline-Delimited JSON (NDJSON): each line in the response is a complete, independently parseable JSON object. This format is used by log services, AI completion APIs, and database export endpoints. The stream delivers lines as they are ready on the server, so you can render results one by one rather than waiting for the full dataset.

Because chunks do not align neatly with newlines, you need a small buffer to accumulate bytes until a newline is found, then parse the complete line.

async function processNDJSONStream(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';

while (true) {
const { done, value } = await reader.read();

if (done) {
if (buffer.trim()) {
try {
const record = JSON.parse(buffer);
console.log('Final record:', record);
} catch {
console.warn('Could not parse final buffer:', buffer);
}
}
console.log('NDJSON stream complete.');
break;
}

buffer += decoder.decode(value, { stream: true });

const lines = buffer.split('n');
buffer = lines.pop();

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const record = JSON.parse(trimmed);
console.log('Received record:', record);
} catch (err) {
console.warn('Skipping unparseable line:', trimmed);
}
}
}
}

processNDJSONStream('https://example.com/api/stream');

Expected output:

Received record: { id: 1, message: "First event" }
Received record: { id: 2, message: "Second event" }
NDJSON stream complete.

The lines.pop() trick is the heart of this pattern: split(‘n’) always produces at least one element, and the last element after splitting is whatever byte sequence has arrived since the last newline. it may be a partial line. Keeping it in buffer ensures it gets completed in the next iteration.

Aborting a Stream with AbortController

Streams can be long-lived. If the user navigates away, closes a panel, or explicitly cancels an operation, you need to stop the fetch otherwise the browser continues downloading data indefinitely, burning bandwidth and holding open a server connection.

AbortController integrates directly with fetch(). Calling controller.abort() causes the pending reader.read() promise to reject with an AbortError, which you can catch and handle gracefully.

const controller = new AbortController();
const { signal } = controller;

async function startStream() {
try {
const response = await fetch('https://httpbin.org/drip?duration=10&numbytes=5000&code=200', {
signal
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let received = 0;

while (true) {
const { done, value } = await reader.read();
if (done) {
console.log(`Stream completed. Total: ${received} bytes.`);
break;
}
received += value.byteLength;
console.log(`Chunk received. Running total: ${received} bytes.`);
}

} catch (err) {
if (err.name === 'AbortError') {
console.log('Stream was cancelled by the user.');
} else {
console.error('Unexpected error:', err);
}
}
}

// Start the stream
startStream();

// Cancel after 2 seconds to simulate a user action
setTimeout(() => {
console.log('Aborting stream...');
controller.abort();
}, 2000);

Expected output:

Chunk received. Running total: 512 bytes.
Chunk received. Running total: 1024 bytes.
Aborting stream...
Stream was cancelled by the user.

Always check err.name === ‘AbortError’ rather than testing the message string. the message is not guaranteed to be consistent across browsers.

Cancelling is clean: the browser closes the underlying TCP connection and no further chunks are delivered.

The ReadableStream interface exposed by fetch().body is one of the most powerful and underused APIs available in the browser. Instead of treating every HTTP response as an atomic unit to wait for, you can treat it as a live channel of data processing, rendering, and reacting incrementally.

The four patterns covered here form a complete toolkit: reading raw chunks with a getReader() loop, decoding bytes to UTF-8 text with a stream-aware TextDecoder, assembling complete records from a newline-delimited stream, and cancelling an in-progress stream with AbortController. Together they cover the majority of real-world streaming use cases you will encounter.

The next time you build a feature that displays large datasets, live logs, or AI-generated text, reach for response.body.getReader() instead of response.json(). Your users will notice the difference immediately.


Streaming Data from the Server was originally published in Client-Side JavaScript on Medium, where people are continuing the conversation by highlighting and responding to this story.

Scroll to Top