Search IOCombats

Search challenges, guides, questions and articles

How do you read a streamed LLM response with ReadableStream and TextDecoder?

Intermediate10 min interview
Skills tested:
Knowing that response.body is a ReadableStream of Uint8ArrayWriting a reader loop with getReader() and read() that stops on doneDecoding bytes to text with a single TextDecoder and the stream optionBuffering text and parsing only complete lines or eventsPushing streamed text into React state without losing updates

Advertisement

🧩 Scenario

Your Next.js API route already streams model output. The interviewer asks you to write the client code that shows the answer word by word in a React component, without using any AI SDK.

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

ApproachProsCons
getReader() + read() loopWorks wherever fetch streaming works, full controlMore code, manual decoder and lock handling
pipeThrough(new TextDecoderStream())Strings out, no manual decode callsStill need your own line or event buffer
for await (const chunk of res.body)Shortest codeOlder Safari versions do not support async iteration of ReadableStream
Provider SDK or AI SDK helperParsing, typing and errors handled for youLess visibility into what happens on the wire

What Interviewers Actually Check

  • Whether you know res.json() and res.text() defeat streaming
  • Whether you can write the read() loop and stop correctly on done
  • Whether you use one TextDecoder with { 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

  1. How would you cancel this read loop when the user navigates away from the page?
  2. How does this loop change when the server sends SSE instead of NDJSON?
  3. What happens if the server sends an error halfway through the stream?
  4. How would you avoid re-rendering the whole chat list on every chunk?
  5. What does reader.releaseLock() do, and when would reader.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 value and done are on each read()?
  • Can you explain the stream: true option 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement