DEV Community

babycat
babycat

Posted on

Your AI Stream Arrives All at Once After Deploy: Debugging Buffered SSE Step by Step

The failure: a stream that only looks streamed

The scenario below is illustrative — a composite of the kind of bug this workflow is built to catch, not a report of a specific incident or a measured deployment.

You ship a chat panel that streams tokens from a model. On localhost the words arrive one at a time, the caret blinks at the end, and the loading state disappears almost immediately. After the same frontend goes behind a deployed server, the bubble stays empty for several seconds and then the entire answer appears in a single paint. Nothing in the React or Vue component changed, and DevTools still reports 200.

The instinct is to blame the model or the renderer, and both are usually innocent here. What changed is the path between the upstream response and your res.body reader. The workflow below is transport-first: prove where bytes are being held before you touch a single line of UI code.

Step 1: Measure arrival time per chunk, not characters rendered

Rendering failures and delivery failures look similar on screen, and they need completely different fixes. Make delivery observable first, because a stream that arrives in one piece cannot be fixed by debouncing state updates.

// illustrative: paste into the browser console; assumes a streaming endpoint
async function probe(url) {
  const t0 = performance.now();
  const res = await fetch(url, { headers: { Accept: "text/event-stream" } });
  const reader = res.body.getReader();
  const marks = [];
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    marks.push({ at: Math.round(performance.now() - t0), bytes: value.byteLength });
  }
  console.table(marks);
  console.log("chunks:", marks.length);
}
Enter fullscreen mode Exit fullscreen mode

How to read the result:

  • Many marks spread across the total duration → transport is healthy; the bug is in rendering, state batching, or announcement cadence.
  • One mark at the end whose at roughly equals the full answer latency → something buffered the response; continue to Step 2.
  • Marks that pause for tens of seconds and then resume → a proxy idle timeout or reconnect, not a stalled model.

Write your own numbers down and keep them in the bug report. They describe one network path on one day, so they are diagnostic evidence, not a benchmark.

Step 2: Read the headers before reading framework source

A response can look perfectly fine in the Network panel and still be undeliverable incrementally. Two headers cause most of the damage, and both are usually set by middleware you did not write for this route.

  1. Content-Encoding: gzip on an event-stream route lets a compression buffer accumulate output until a size threshold is reached.
  2. A Content-Type other than text/event-stream makes some clients buffer the body or refuse incremental parsing.
# illustrative: -N disables curl buffering, -D - prints response headers
curl -N -D - -o /dev/null "https://your-host.example/chat"
Enter fullscreen mode Exit fullscreen mode

Next, timestamp each line as it arrives, because identical timestamps mean the bytes left your process together or never left it at all.

# illustrative: timestamped frames expose where the pause lives
curl -N "https://your-host.example/chat" \
  | while IFS= read -r line; do printf '%s %s\n' "$(date +%s.%N)" "$line"; done
Enter fullscreen mode Exit fullscreen mode

If every timestamp is the same, the hold happens before your handler writes — an edge proxy, a compression layer, or a framework wrapper that collects the whole body before responding.

Step 3: Symptom → likely cause → fix layer

Use this table as a triage checklist rather than a diagnosis. Each row points at one layer to inspect, which keeps you from rewriting the client while a proxy is eating the frames.

Symptom Likely cause Quick check Fix layer
Everything arrives in one chunk at the end Compression or response buffering curl -N timestamps all identical Proxy / middleware
200 but no frames ever parse Missing trailing blank line, wrong Content-Type Header dump shows application/json Server route
Stream runs for a while, then stops Proxy read or idle timeout Gap length repeats predictably Heartbeat frames
Works on origin host, fails behind CDN Edge buffering or a cache rule on the route Compare direct URL with CDN URL CDN config
Text fine, characters corrupted Decoder misuse or split multi-byte sequences Inspect raw bytes at the boundary Client decoding

Two notes on the table. A missing blank line after each data: field is a parser bug that looks like buffering because nothing renders until the connection ends. And reconnects can duplicate frames, so give each event an id before you start retrying.

Step 4: Fix at the layer that buffers, not in the component

If your own server sits in front of the model, send the headers before the first token exists. The snippet below is illustrative and minimal; it is not hardened for production traffic, auth, or backpressure.

// illustrative: server.mjs, Node 20+, no dependencies
import http from "node:http";

const UPSTREAM = process.env.UPSTREAM_URL; // model endpoint that supports streaming
const KEY = process.env.UPSTREAM_KEY;

http.createServer(async (req, res) => {
  if (req.url !== "/chat") { res.writeHead(404).end(); return; }

  res.writeHead(200, {
    "Content-Type": "text/event-stream; charset=utf-8",
    "Cache-Control": "no-cache, no-transform",
    "Connection": "keep-alive",
    "X-Accel-Buffering": "no", // nginx: do not buffer this response
  });
  res.flushHeaders(); // header reaches the client before any token exists

  const upstream = await fetch(UPSTREAM, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
    body: JSON.stringify({ stream: true }),
  });

  const reader = upstream.body.getReader();
  const dec = new TextDecoder();
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    const text = dec.decode(value, { stream: true });
    res.write(`data: ${text.replace(/\n/g, "\ndata: ")}\n\n`);
  }
  res.write("data: [DONE]\n\n");
  res.end();
}).listen(Number(process.env.PORT ?? 3000));
Enter fullscreen mode Exit fullscreen mode

Honest caveats about that code: re-wrapping decoded text into fresh data: frames flattens the upstream event structure, and a real proxy should forward original frames instead. Dropping { stream: true } in the decoder will corrupt characters split across chunk boundaries. And X-Accel-Buffering only helps when nginx (or a compatible proxy) is actually in front of you.

On the client side, look for code that quietly consumes the stream:

  • await res.text() in a logging middleware, cache wrapper, or error reporter destroys incrementality.
  • Dev servers and preview proxies sometimes buffer; test the deployed URL, not just localhost.
  • Service worker or CDN rules that match the chat route can cache or coalesce the response.

Step 5: Verify with a matrix, not with a vibe

Run these checks against the deployed URL after the fix. Record what you observe in your own environment; the pass column describes the shape of a healthy result, not measured values from someone else's machine.

Check How to run it Pass criteria
Headers curl -N -D - -o /dev/null text/event-stream, no gzip on the route
Frame spacing Timestamped curl -N loop Timestamps spread across the answer
Browser delivery probe() from Step 1 More than one chunk for multi-token answers
Cancellation Abort mid-stream, watch server logs Upstream read stops, no trailing writes
Reconnect safety Drop the connection, reconnect No duplicated data: ids rendered

If two of five checks fail in the same layer, fix that layer rather than adding client workarounds. Client-side smoothing hides the symptom and leaves the real buffering in place for every other consumer of the endpoint.

The accessibility consequence: cadence is a design decision

When chunks coalesce, the final text is identical but the announcement cadence changes completely, and assistive technology users get a different experience from sighted ones. Keep politeness and update throttling decisions in the client, so transport behavior cannot dictate how often a live region speaks. Transport fixes and announcement fixes are separate tasks, and mixing them usually produces a UI that is quiet for the wrong reason.

Where a free sandbox actually helps this workflow

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source project, and the operator describes the current offer as free model access, including a 10-million-token allowance, plus a free server option. Availability, quotas, and server capacity can change, so confirm the current terms on the project page before you plan a budget or a public demo around them.

The reason it fits this particular bug is structural: buffered streaming usually appears only when a proxy sits between browser and model, which is exactly what localhost lacks. A free server option gives you that middle layer to deploy the proxy above and reproduce the one-chunk symptom without touching production. Treat it as a debugging sandbox and a place to try the header checks, not as a latency baseline for your product.

If you want to reproduce the failure before you fix it, deploying the snippet above to a free server instance and pointing it at a free model endpoint is a reasonable first attempt.

Limitations and who should skip this approach

  • Teams with strict latency or uptime obligations should not run production traffic through free tiers; keep them for reproduction and experiments.
  • If you do not control the proxy or CDN in front of your origin, the fix belongs to the platform team, and client workarounds only mask the issue.
  • If the upstream endpoint does not support streaming at all, no header change will help; confirm with the provider first.
  • If the response must be post-processed (moderation, tool calls, citation injection), you need a design decision about streaming partially validated output, which is out of scope here.
  • Workflows with regulated data should not use third-party sandboxes for reproduction, even temporarily.

If it still buffers, send this

A useful reproduction report for this class of bug contains: browser and OS versions, the assistive technology you tested with if any, the hosting layer between browser and model, the header dump from curl -N -D -, the chunk count and timestamps from the probe, and whether the symptom appears on the origin URL but not the CDN URL. Include the exact transition that failed — first token, reconnect, or cancel — because that detail usually separates a proxy problem from a client one.

Top comments (0)