Most engineers blame the network when an llm stream stalls mid response, but the root cause is usually deeper: token generation backpressure, server-side buffering, or a client that stops pulling bytes. This analysis breaks down where the bytes actually halt and how to fix it without guesswork.
The anatomy of a stall
Server-Sent Events (SSE) over HTTP is the de facto transport for LLM streaming. The server sends text/event-stream chunks, each prefixed with data: and terminated by a blank line. A healthy stream emits a chunk every few tens of milliseconds per token. A stall is a gap where no bytes cross the wire for seconds—or until the connection silently drops.
The symptom is always the same: your UI freezes mid-sentence, your parser throws because it got a partial JSON object, or your async for loop just hangs. But the cause is rarely packet loss.
Where the bytes actually stop
Provider-side batching and decode latency
Transformer inference is not uniform. Prefill of a long prompt can take seconds before the first token leaves the GPU. Even after that, variable-length attention steps, speculative decoding rollbacks, or KV-cache eviction can cause a single token to take 2–5 seconds while the next ten arrive in 50 ms bursts.
If you watch a raw stream, you’ll see bursts then silence. That is not a network issue; it is the model itself. When a provider is rate-limited, some gateways throttle by delaying the stream rather than returning 429. That produces exactly the llm stream stalls mid response pattern you’re debugging.
Gateway and proxy buffering
Most production stacks put an LLM endpoint behind nginx, Envoy, or a serverless wrapper. Default proxy configs buffer responses to optimize TCP throughput. nginx, for example, will accumulate upstream data and flush only when the buffer fills or the response ends—directly defeating SSE.
location /v1/ {
proxy_pass https://backend;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
# critical for SSE through nginx
proxy_set_header X-Accel-Buffering no;
}
If you don’t control the proxy, send X-Accel-Buffering: no from the app layer. Cloudflare and ELB have similar knobs; read their docs for Cache-Control: no-transform and target group deregistration delays.
Client consumption bugs
The most common self-inflicted stall is a client that blocks on per-chunk processing. Consider this TypeScript snippet:
const res = await fetch("/v1/chat/completions", { method: "POST", body, headers });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
// BLOCKING: parse + heavy DOM update per chunk
updateEditor(JSON.parse(extractJson(text)));
}
If updateEditor runs synchronous layout work, the microtask queue stalls and the next reader.read() is delayed. The server may have sent data, but your TCP window closed because you weren’t pulling. The fix is to decouple network reads from rendering via a queue and requestAnimationFrame.
Reproducing llm stream stalls mid response
Before changing infrastructure, measure. A raw curl with line timestamps exposes gaps immediately:
curl -N -s https://api.example.com/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"write a long story"}]}' \
| while IFS= read -r line; do echo "$(date +%s.%N) $line"; done
Any gap > 1s in the timestamp column is a stall. Repeat the same request with the OpenAI SDK and compare; if the SDK appears smoother, the stall is likely client-side buffering in your own code.
For programmatic detection, use an async Python probe:
import asyncio, time, httpx
async def watch():
url = "https://api.example.com/v1/chat/completions"
headers = {"Authorization": "Bearer KEY", "Content-Type": "application/json"}
payload = {"model": "gpt-4o", "stream": True,
"messages": [{"role": "user", "content": "explain distributed consensus"}]}
async with httpx.AsyncClient(timeout=httpx.Timeout(None)) as c:
async with c.stream("POST", url, headers=headers, json=payload) as r:
last = time.monotonic()
async for line in r.aiter_lines():
if not line.startswith("data:"):
continue
now = time.monotonic()
gap = now - last
if gap > 1.0:
print(f"STALL {gap:.2f}s before: {line[:50]}")
last = now
asyncio.run(watch())
This prints only the outliers. If you see stalls here, the problem is upstream of your app code.
Debugging checklist
- Hit the raw endpoint with
curl -Nbefore involving SDKs. - Inspect response headers:
Transfer-Encoding: chunked,Cache-Control, and absence ofContent-Length. - Disable all proxy buffering at nginx/Envoy; set
X-Accel-Buffering: no. - Check client read loop for synchronous work between
read()calls. - Correlate with token counts: if the stream resumes exactly after a billing meter increment, the provider paused for quota, not compute.
An OpenAI-compatible gateway such as n4n.ai can automatically fall back to a secondary provider when the primary is rate-limited, which hides some llm stream stalls mid response caused by provider degradation—but it won’t fix a client that blocks on JSON parsing per chunk.
Tradeoffs: responsiveness vs throughput
Teams often “fix” stalls by buffering tokens client-side and flushing every 200 ms. That improves UI smoothness but masks real latency spikes and makes debugging harder. A better pattern is to render incrementally but use a watchdog:
let last = Date.now();
const watchdog = setInterval(() => {
if (Date.now() - last > 3000) console.error("stream dead?", Date.now() - last);
}, 1000);
// in read loop: last = Date.now() on each chunk
You preserve liveness signals without sacrificing per-token rendering. If you batch, log the original inter-chunk arrival times separately so you never lose visibility.
Routing and cache-control hints
When you send a streaming request through a gateway that honors client routing directives, a stall on one provider may not follow you to another. Forwarding provider cache-control hints (e.g., no-store for ephemeral generations) prevents intermediary CDNs from holding the first byte. If your gateway supports per-token metering, reconcile the stall window against billed tokens: zero new tokens during the gap means generation stopped; tokens billed but not received means transport ate them.
Decisive takeaway
An llm stream stalls mid response almost never because the Internet broke. Instrument the raw SSE feed first, kill proxy buffering second, and make your client read loop non-blocking third. Only then consider gateway-level fallback for provider-side degradation. Ship a watchdog, log arrival gaps, and treat any silent pause longer than your p95 token latency as a bug, not a feature.