How do you read a streamed LLM response with ReadableStream and TextDecoder?
Advertisement
🧩 Scenario
Concept Walkthrough
What fetch actually gives you
When you await fetch(), the promise resolves as soon as the headers arrive. The body may still be coming. response.body is a ReadableStream<Uint8Array>: a source you can pull chunks of raw bytes from as they land.
If you call res.json() or res.text(), the browser waits for the whole body before giving you anything. That removes the point of streaming. To show tokens as they arrive, you read the stream yourself.
The reader loop
res.body.getReader() locks the stream and returns a reader. Each await reader.read() resolves with { value, done }. While done is false, value is a Uint8Array chunk. When the server closes the response, done becomes true and you stop.
Think of it like a paginated API where you keep calling next() until there are no more pages, except the pages are byte chunks and you do not choose their size.
Bytes to text
Bytes are not text yet. A TextDecoder turns UTF-8 bytes into a string. The important detail: create one decoder for the whole stream and call decoder.decode(value, { stream: true }) for each chunk. MDN describes the stream flag as indicating that more data will follow. With it, the decoder holds back an incomplete character instead of outputting a broken one. Call decoder.decode() once more at the end to flush anything left.
The shorter version is res.body.pipeThrough(new TextDecoderStream()), which gives you a stream of strings directly.
Chunks are not messages
A chunk is just "what arrived now". It can contain two events, half an event, or one event plus half the next. So you append decoded text to a buffer, split on your delimiter (a newline for NDJSON, a blank line for SSE), process the complete parts, and keep the remainder for the next read.
Key Code Explained
// Generic helper: read a streaming response line by line.
export async function readLines(
res: Response,
onLine: (line: string) => void,
): Promise<void> {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error('Response has no body to stream');
const reader = res.body.getReader();
const decoder = new TextDecoder(); // one decoder for the whole stream
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
// stream: true keeps incomplete multi-byte characters for the next call
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // last item may be an incomplete line
for (const line of lines) {
if (line.trim()) onLine(line);
}
}
// Flush the decoder and any final line without a trailing newline
buffer += decoder.decode();
if (buffer.trim()) onLine(buffer);
} finally {
reader.releaseLock();
}
}
// Using it in a React component (endpoint sends NDJSON: {"text":"..."} per line)
import { useState } from 'react';
export function Answer() {
const [text, setText] = useState('');
async function ask(prompt: string) {
setText('');
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
await readLines(res, (line) => {
const event = JSON.parse(line) as { text: string };
// Functional update: each call sees the latest state, not a stale closure
setText((prev) => prev + event.text);
});
}
return (
<div>
<button onClick={() => ask('Explain closures')}>Ask</button>
<p>{text}</p>
</div>
);
}
lines.pop() is the key line. After splitting, the last element is whatever came after the final newline, which may be an unfinished line, so it goes back into the buffer. The functional setText update matters because the callback runs many times inside one async function. A plain setText(text + event.text) would keep reading the text value captured when ask started.
Tradeoffs
| Approach | Pros | Cons |
|---|---|---|
| getReader() + read() loop | Works wherever fetch streaming works, full control | More code, manual decoder and lock handling |
| pipeThrough(new TextDecoderStream()) | Strings out, no manual decode calls | Still need your own line or event buffer |
| for await (const chunk of res.body) | Shortest code | Older Safari versions do not support async iteration of ReadableStream |
| Provider SDK or AI SDK helper | Parsing, typing and errors handled for you | Less visibility into what happens on the wire |
What Interviewers Actually Check
- Whether you know
res.json()andres.text()defeat streaming - Whether you can write the
read()loop and stop correctly ondone - Whether you use one
TextDecoderwith{ stream: true }and flush at the end - Whether you buffer partial lines or events instead of parsing raw chunks
- Whether your React state updates survive many rapid calls
Follow-Up Questions
- How would you cancel this read loop when the user navigates away from the page?
- How does this loop change when the server sends SSE instead of NDJSON?
- What happens if the server sends an error halfway through the stream?
- How would you avoid re-rendering the whole chat list on every chunk?
- What does
reader.releaseLock()do, and when wouldreader.cancel()be better?
Common Candidate Mistakes
- Using
await res.text()and wondering why the UI only updates at the end - Writing
new TextDecoder().decode(value)inside the loop, which creates a fresh decoder per chunk - Calling
JSON.parse(chunk)on raw chunks and crashing on the first split event - Forgetting the final flush, which can drop the last line if it has no trailing newline
- Ignoring
res.ok, so a 401 or 429 error body gets rendered as if it were the answer
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you write the reader loop without looking anything up?
- Can you explain what
valueanddoneare on eachread()? - Can you explain the
stream: trueoption in one sentence? - Can you explain why the line buffer and
lines.pop()exist? - Can you hook the loop into React state without stale closures?
Summary
fetch resolves when headers arrive, and response.body is a ReadableStream of byte chunks. Get a reader, call read() until done is true, and decode each chunk with one shared TextDecoder using { stream: true }, flushing once at the end. Network chunks have no relation to tokens or events, so append decoded text to a buffer and only parse complete lines or events. In React, push text into state with functional updates. TextDecoderStream and async iteration shorten the code, but the buffering rule stays the same.
Can I use for await...of on response.body?
ReadableStream is async iterable in Node and in modern browsers (Chrome 124+ and Firefox 110+ per caniuse), but Safari added support much later. If you support older Safari versions, use the getReader() and read() loop, which works everywhere fetch streaming works.
What is the difference between TextDecoder and TextDecoderStream?
TextDecoder is a plain object you call decode() on yourself. TextDecoderStream is a TransformStream you pipe the body through with pipeThrough, and it outputs strings instead of bytes. Both handle characters split across chunks when used correctly.
Does one chunk equal one token?
No. A chunk is whatever bytes arrived from the network at that moment. It can contain several SSE events, half of one, or even half of a single character.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement