TechEarl

Stream a fetch() Response in JavaScript (NDJSON, line-by-line)

Read a fetch() response as it arrives instead of buffering the whole body. Async-iterate response.body, decode with TextDecoderStream, and split NDJSON line by line with a dependency-free TransformStream. The same pattern that consumes SSE and streaming LLM token responses.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
Stream a fetch() response in JavaScript: async-iterate response.body, decode with TextDecoderStream, and parse NDJSON line by line with no dependencies.

The short version: await response.json() waits for the whole body to arrive before you get a single byte. If the server is streaming (a long list of records, server-sent events, an LLM emitting tokens), you want to read the body as it lands. response.body is a ReadableStream, and the modern way to consume it is to async-iterate it:

javascript
const response = await fetch("/api/events");

for await (const chunk of response.body) {
  // chunk is a Uint8Array of raw bytes, as they arrive over the wire
  console.log(chunk.length, "bytes");
}

That is the whole idea. The rest of this page is the detail that makes it useful: decoding bytes to text, splitting a stream into lines (NDJSON), why this is exactly how you read SSE and streaming model responses, backpressure, and the getReader() fallback for environments that do not support async iteration yet.

First, check response.ok before you stream. fetch does not reject on a 404 or 500; it resolves, and you would happily start streaming an error page. That gotcha is covered in full in the fetch() API guide, so I will not relitigate it here, but the pattern is if (!response.ok) throw new Error(response.status) before the loop.

Decode the bytes to text with TextDecoderStream

The chunks you get from response.body are Uint8Array byte buffers, and a multi-byte UTF-8 character can be split across two chunks. Do not decode each chunk in isolation with new TextDecoder().decode(chunk): a chunk boundary landing mid-character gives you mojibake. The right tool is TextDecoderStream, which is stateful and carries a partial character over to the next chunk.

Pipe the byte stream through it and iterate the text stream instead:

javascript
const response = await fetch("/api/events");
const textStream = response.body.pipeThrough(new TextDecoderStream());

for await (const text of textStream) {
  // text is a string, correctly decoded across chunk boundaries
  process.stdout.write(text);
}

pipeThrough returns the readable side of the transform, so you can keep chaining. That is the hook for the next step.

Split a stream into NDJSON lines (no dependencies)

NDJSON (newline-delimited JSON) is one JSON object per line. It is the standard shape for streaming APIs because each line is independently parseable the moment its trailing newline arrives. The catch: stream chunks do not align to line boundaries. One chunk might hold two and a half lines; the half-line has to wait for the next chunk.

You do not need a library for this. A small TransformStream buffers text, emits whole lines, and holds the remainder:

javascript
function teLineSplitter() {
  let buffer = "";
  return new TransformStream({
    transform(chunk, controller) {
      buffer += chunk;
      const lines = buffer.split("\n");
      buffer = lines.pop(); // last element is the incomplete trailing line
      for (const line of lines) {
        if (line) controller.enqueue(line);
      }
    },
    flush(controller) {
      // emit whatever is left when the stream ends (no trailing newline)
      if (buffer) controller.enqueue(buffer);
    },
  });
}

Chain it after the decoder and parse each line as it pops out:

javascript
const response = await fetch("/api/records");
if (!response.ok) throw new Error(`HTTP ${response.status}`);

const lines = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(teLineSplitter());

for await (const line of lines) {
  const record = JSON.parse(line);
  handle(record); // runs per record, as it arrives, not after the whole response
}

The first record is in your hands the moment the first newline lands, regardless of how many thousands follow. Memory stays flat because you never hold the full body, only one line plus the current buffer tail.

This is how you read SSE and streaming LLM responses

This pattern is not niche. It is the foundation of every streaming chat UI you have used. Server-sent events and the OpenAI/Anthropic-style token streams are both line-oriented text over a single long-lived fetch response. SSE frames are blank-line-separated blocks of data: lines; an LLM token stream is usually NDJSON or SSE where each frame carries the next token.

So the splitter above gets you most of the way to consuming a model response by hand, without the SDK:

javascript
const response = await fetch("https://api.example.com/v1/chat", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ model: "...", stream: true, messages }),
});

const lines = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(teLineSplitter());

for await (const line of lines) {
  if (!line.startsWith("data: ")) continue;
  const payload = line.slice(6);
  if (payload === "[DONE]") break;
  const { delta } = JSON.parse(payload);
  if (delta) appendToken(delta); // paint the token the instant it arrives
}

The difference between this and await response.json() is the difference between a chat box that types as the model thinks and one that sits blank for ten seconds and then dumps a wall of text. Same network call, different consumption.

Backpressure comes for free

When you iterate slowly (parsing each record into a database, painting to the DOM, awaiting inside the loop), the stream notices. for await...of only pulls the next chunk when your loop body returns, so a slow consumer naturally throttles the producer. With piped transforms, that backpressure propagates all the way up the chain to the network read. You are not buffering the entire body in memory while a slow handler falls behind. This is the quiet advantage of streams over reading everything into an array first, and you get it without writing any flow-control code.

The async generator machinery underneath for await...of is the same engine described in how async functions really work; a ReadableStream is just an async-iterable like any other.

The getReader() fallback for older runtimes

Async iteration over response.body is the newest piece here, and its support landed unevenly. Firefox shipped the default Symbol.asyncIterator on ReadableStream first (Firefox 110, 2023), Chrome and Edge followed in 124 (April 2024), Node.js has had it since 18, and Safari was the long holdout: it only gained async iteration in Safari 26.4. So if you target older Safari, or any browser before those cutoffs, the stream is there but the for await...of shortcut is not. Drop to the reader API, which has been around far longer and works everywhere ReadableStream does:

javascript
const reader = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(teLineSplitter())
  .getReader();

try {
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    handle(JSON.parse(value));
  }
} finally {
  reader.releaseLock();
}

It is the same stream, read manually. read() returns { value, done }, you loop until done, and you releaseLock() (or cancel()) when you stop early so the stream is not left locked. The async-iterator form does this cleanup for you, which is why it is the default once your targets support it.

Browser and Node support

FeatureChrome / EdgeFirefoxSafariNode.jsNotes
response.body as ReadableStream436510.118 (global fetch)The stream itself; read via getReader() everywhere.
TextDecoderStream7110516.418Cross-browser baseline since Sep 2022.
TransformStream6710214.118Web Streams in Node since 18.
Async iteration (for await...of on response.body)12411026.418Newest; getReader() is the fallback below these.

TextDecoderStream, TransformStream, and global fetch all landed in Node.js 18, so the entire NDJSON pipeline above runs unchanged server-side. The one feature you actually have to watch is async iteration: Safari only got it in 26.4 and Chrome in 124, so for broad browser reach the getReader() form is still the portable choice. Everything else is baseline.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsfetchReadableStreamStreams APINDJSONTextDecoderStreamTransformStreamSSEstreamingJavaScript

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts