DEV Community

Cover image for Your Fetch Already Streams. You're Buffering It Anyway.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Your Fetch Already Streams. You're Buffering It Anyway.

You build a chat feature. The reply should feel alive — words appearing one at a time, like the model is thinking out loud. You wire up a setInterval that reveals one character every 20ms over the response text, and it works. Reload the page, send a message, watch the words type themselves out. Ship it.

Then someone asks why the "typing" doesn't start until the reply is already fully generated server-side. You watch the network tab. The request sits at "pending" for the entire generation time — three seconds, five seconds, however long the full answer takes — and only then does your beautiful typewriter animation begin, replaying an answer that finished being written a moment ago.

The animation was never streaming anything. It was a flipbook drawn after the fact.

The obvious fix, and why it isn't one

Here's the code that got you there:

async function getReply(prompt) {
  const res = await fetch("/api/chat", {
    method: "POST",
    body: JSON.stringify({ prompt }),
  });
  const { reply } = await res.json();
  typewriter(reply); // reveal one character at a time
}
Enter fullscreen mode Exit fullscreen mode

It looks like streaming. Characters appear over time, in order, at a readable pace. But look at where the await sits: res.json() doesn't resolve until the server has sent every byte of the response and the browser has parsed the whole thing into an object. The entire reply exists in memory, complete, before typewriter() gets called at all. The animation is decoration bolted onto a request-response cycle that was never partial to begin with.

This is the trap: a fake typewriter effect and a real streaming response produce the exact same visual result on a fast connection. You can't tell them apart by looking at the finished page. You can only tell them apart by watching when the first pixel of text actually has something behind it — and by then it's a five-second bug already living in production.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

What res.json() is quietly doing

fetch() doesn't hand you a finished string. It hands you a Response object whose body property is a real, honest-to-spec ReadableStream<Uint8Array> — a stream of raw byte chunks arriving from the network over time, same as any other stream on the platform. You can read from it directly, chunk by chunk, the moment each one lands.

res.json() and res.text() are convenience wrappers. Under the hood they call getReader() on that same stream, read every chunk until the stream is done, concatenate the bytes, decode them, and then resolve. They're not lying about being async — they really do wait — but they wait for the entire body, every time, no matter how large it is or how long it takes to arrive. Calling one of them is an explicit choice to buffer, even when the whole reason you reached for a stream-shaped API was to avoid exactly that.

Read the chunks yourself instead, and nothing forces you to wait for the last one before using the first:

async function getReply(prompt) {
  const res = await fetch("/api/chat", {
    method: "POST",
    body: JSON.stringify({ prompt }),
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

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

    // value is a Uint8Array chunk — decode it and show it now, not later
    const text = decoder.decode(value, { stream: true });
    appendToChat(text);
  }
}
Enter fullscreen mode Exit fullscreen mode

reader.read() resolves as soon as a chunk shows up, not when they all have. The { stream: true } flag on the decoder matters more than it looks: a multi-byte UTF-8 character can land split across two chunks, and without that flag TextDecoder would mangle the half it sees instead of holding it back to join with the rest. Now the first words appear the instant the server writes them, and the "typing" animation you built earlier is redundant — the real arrival rate is the animation.

The gotcha that catches people once

Once you call res.body.getReader(), the body is locked to that reader. Call res.json() or res.text() on the same response afterward — say, because some shared logging helper touches every response — and it throws a TypeError, because the stream has already been (or is being) consumed. Response.bodyUsed flips to true the moment anything starts draining the body, streamed or buffered, and a Response only gets read once. If you need the raw text and need to stream, clone the response with res.clone() before touching either copy.

The lesson underneath the API

This isn't really a story about chat UIs. It's about a habit: reaching for a convenience method (.json(), .text(), .blob()) out of reflex, without noticing it silently collapses something that arrived over time into something that arrives all at once. The fix is never "add an animation to fake the streaming back in" — it's checking whether a buffering call already ate the streaming you wanted, upstream of where you're standing.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Next time you catch yourself writing a progress bar, a typing effect, or a loading spinner over a value that "just needs a beat to feel right" — check whether something underneath already knows the real timing, and you're just not asking it. What's the last place in your own code where you faked a delay that was already real?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)