DEV Community

Remdore
Remdore

Posted on AI-assisted

nginx streams your tokens fine. HAProxy holds them for 206ms.

Frames-per-read metric proves HAProxy buffers SSE

The advice is everywhere: if you are streaming Server-Sent Events through
nginx, turn off proxy_buffering or your tokens will arrive in one lump.

I built a rig to measure it. nginx does not buffer a token stream. The proxy
that does is HAProxy, on a stock config, and it holds your first token for
206 milliseconds. The header people reach for to fix SSE buffering has no
effect on it, because that header is an nginx convention and nginx was never
the problem.

Four proxies, pinned to exact patch versions, all in front of the same
emitter, measured on a Linux droplet:

proxy time to first token gap between frames frames per read
direct (no proxy) 2ms 50.0ms 1.02
nginx 1.31.5 3ms 50.0ms 1.02
nginx 1.31.5, proxy_buffering off 2ms 50.0ms 1.02
Caddy 2.11.4 2ms 50.0ms 1.02
Traefik v3.7.13 2ms 50.0ms 1.02
HAProxy 3.4.4 206ms 0.0ms 5.12

The emitter sends a frame every 50ms. Through nginx, Caddy and Traefik the
frames arrive 50ms apart, one per read, indistinguishable from no proxy at
all. Through HAProxy they arrive in bursts of five with zero gap between
them, 206ms late.

proxy_buffering is on by default in that nginx row. I checked with
nginx -T inside the running container rather than trusting the config file.
Turning it off changes nothing.

The metric

Everything here rests on one number: frames per read. Take the number of
SSE frames the client received, divide by the number of recv() calls that
delivered at least one. 1.0 means every frame arrived on its own. 41.0 means
the entire stream landed in a single read.

It is a ratio, so it needs no baseline to interpret and it survives a noisy
host in a way that absolute latency does not.

Before trusting it I pointed it at a relay that deliberately drains an entire
upstream response and then flushes it in one go. Direct: 1.00. Through the
relay: 21.00. The instrument detects coalescing when coalescing is there.

Two numbers in this post are not findings, and I want that on the record
before the tables start:

  • Frames per read has a noise floor around 1.02. The direct, unproxied path measures 1.02. One merged read in 41 is the client and the kernel, not a proxy. Anyone reporting 1.02 as distinct from 1.00 is reading noise.
  • This host produces isolated 30-45ms scheduling spikes with no proxy and no Docker in the path at all. Emitter alone, 40 runs: p90 of 10.89ms, max of 44.50ms. So no ~40ms first-token stall can be pinned on a proxy from this rig. More on that below, because I nearly published one.

Token-sized frames are HAProxy's worst case

This is the part that makes it matter for anyone streaming from a model.

Same 50ms interval, same HAProxy, same stock config. The only change is
growing each frame from about 60 bytes to about 1.1KB:

frame size time to first token gap between frames frames per read
~60 bytes 206ms 0.0ms 5.12
~1.1KB 53ms 41.0ms 1.46

Grow the payload and the coalescing largely goes away. First token four times
faster, frames arriving spaced near the emit interval instead of in bursts,
close to one frame per read.

Varying the emit rate points the same direction. Frames per read goes 13.67 at
a 5ms interval, 5.12 at 50ms, 2.05 at 200ms. Faster emission means more bytes
accumulating per unit time, and more bytes means earlier flushes.

So the trigger is substantially about filling a buffer, not a fixed timer. I
am not going to name a byte threshold: the values implied by different cells
do not reconcile cleanly, around 2.2KB from one and 1.05KB from another, and
pinning it down properly needs a dedicated sweep I have not run.

But the practical consequence is sharp. An LLM token stream is small frames
arriving steadily
, which is exactly the shape that triggers this. Benchmark
the same proxy with realistic 1KB chunks and you will not see it. That is
probably why it is not better known.

It also means the frames-per-read figure is a property of this stream through
this proxy
, not a property of HAProxy. Quoting "HAProxy delivers 5 events per
read" as a fact about HAProxy would be wrong; it delivers 13.67 per read at a
5ms interval and 2.05 at 200ms.

The header everyone reaches for does nothing

X-Accel-Buffering: no is the documented escape hatch for exactly this
problem. I sent it and measured again:

time to first token frames per read max gap
HAProxy, no header 206ms 5.12 256.4ms
HAProxy, X-Accel-Buffering: no 214ms 5.12 256.2ms

Identical. The 206 against 214ms is run-to-run variation; the ratio and the
max gap match to three significant figures.

It is an nginx convention. nginx honours it, and nginx was not buffering.
HAProxy has never claimed to read it. So the standard fix for SSE buffering
is a header that the only buffering proxy in this set ignores.

When proxy_buffering does cost you something

I could not leave the nginx result as "the advice is unnecessary", because
every cell above uses a client that reads promptly and a stream of about
2.4KB total. nginx's default proxy_buffers is 8 × 4k or 8k, so 32 to 64KB.
The buffers were never close to full. The directive had nothing to do.

So I built the case where it does: about 328KB of payload, pushed through a
client that deliberately sleeps 200ms between reads.

endpoint time to first token frames per read
direct 2ms 3.73
nginx (proxy_buffering on) 53ms 3.73
nginx (proxy_buffering off) 3ms 3.57

There it is. Buffering on costs about 50ms of first-token latency, and it is
consistent: median 53ms, max 54ms across ten runs, never once fast.

Note what did not happen. Frames per read is 3.73 either way, the same as
direct. The coalescing in that column is the slow client and the kernel. The
directive cost latency; it did not turn the stream into one lump.

So the honest position on proxy_buffering is neither of the two things the
internet tells you:

  • Small frames, prompt client — the actual token-streaming case — it costs nothing measurable, across four separate cells.
  • Bulk payloads, slow client — it costs about 50ms of first-token latency.
  • In no cell did it cause the dramatic coalescing it is famous for.

The finding I killed

Early on, measuring on macOS with Docker Desktop, I saw Caddy and Traefik
consistently take about 42ms to first token while nginx took 2ms. The first
request after startup was fast and every reused one was slow, which is the
signature of a delayed-ACK stall on a pooled upstream connection. I had a
mechanism, I could predict which condition would reproduce it, and I built a
2×2 to demonstrate it.

Three things then went wrong with that story, in order.

A reviewer pointed out that my nginx config has no upstream{} block and no
keepalive, so nginx never pools upstream connections at all. It was
permanently in the "fresh connection" regime that Caddy and Traefik only hit
on their first request. I had not been comparing proxies. I had been comparing
pooled against unpooled.

Then I ran the emitter on its own, with no proxy and no Docker in the path,
and it produced 30 to 45ms spikes by itself. Whatever I had been measuring, I
could not attribute it to a proxy.

Then I moved to Linux and it vanished entirely. Across all nine cells and
every condition — pooled or fresh, Nagle on or off — Caddy and Traefik sit at
2 to 5ms. The fresh-conn cell restarts the whole stack five times so every
row is a genuine first request: Caddy 5ms, Traefik 4ms. No difference from
pooled.

It was a Docker Desktop measurement artifact. Reporting it as a proxy
behaviour would have been wrong, and I got within one experiment of doing it.

Does it hold against a real model

Every number above comes from a synthetic emitter, because exact pacing is
what makes the comparison causal. So I swapped the upstream for DigitalOcean's
serverless inference endpoint and measured through the same client, 15 calls,
all HTTP 200:

endpoint frames per read gap between frames frames
HAProxy 5.43 0.0ms 169
nginx 1.04 8.6ms 185
nginx, proxy_buffering off 1.02 8.7ms 176

Synthetic said 5.12 against 1.02. Real tokens from gpt-oss-20b say 5.43
against 1.04.

This table cannot prove causation on its own — a model's time to first token
varies between calls, which is why the synthetic cells carry the argument. Its
only job is to show the effect is not an artifact of my emitter. It is not.

How the numbers were kept honest

Two guards gate everything, and both can void a result.

The emitter records its own send timestamps and exposes them, so after every
run the harness checks whether the emitter actually paced as instructed. If it
drifted, that run is discarded rather than blamed on a proxy. The direct
unproxied path also has to show a clean incremental stream, or no proxy row
from that host means anything.

The suite makes exactly one attempt per cell and never retries. That rule
exists because I broke it. An early run "succeeded" on the sixth attempt, and
when I stopped retrying, one honest attempt certified one cell out of six.
Re-running until the guards pass selects for quiet moments on the machine and
biases every number, invisibly.

Individual runs are discarded, at a rate of 1.7% to 11.7% depending on the
cell, because across roughly 2,400 timed writes per cell the chance of one
scheduling hiccup approaches certainty. That exclusion is only defensible
because it is declared in advance, mechanical, measured on the emitter side
independently of the proxy being tested, and every exclusion is counted in the
published table. Re-running until green fails all four of those tests.

The internal check that convinced me it was working: re-enabling Nagle on the
emitter doubled the exclusion rate from 5.0% to 10.0% and left every proxy
number unchanged. Exclusions were removing apparatus noise, not shaping the
result.

Numbers come from a 2-vCPU Ubuntu 24.04 droplet, not a laptop. Docker Desktop
on macOS routes container traffic through a VM network stack, which puts tens
of milliseconds of noise into a measurement whose signal is 50ms — and, as
above, invented a finding.

That is the short version. The harness itself was wrong in fourteen ways
before it produced a number I would stand behind: a guard that audited one run
in ten, a pacing timestamp on the wrong side of a blocking write, a healthcheck
perturbing the thing it was checking, and the retry loop above doing to my own
numbers precisely what the guards existed to prevent. Every one of those, and
the experiment that proved each fix actually bites, is written up separately in
My benchmark harness was wrong fourteen ways before it measured anything.

What I would still test

  • The exact flush trigger in HAProxy. A payload-size sweep at a fixed interval would settle whether it is a byte threshold, a timer, or both.
  • Whether option http-no-delay removes it, and what that costs.
  • Caddy and Traefik with upstream keepalive explicitly configured, since my configs leave pooling at vendor defaults and nginx's default is no pooling at all.

The harness is stdlib Python and Docker, no dependencies. selftest.sh
certifies the host before any measurement and refuses to run against a server
it did not start. run.sh writes the image digests and the full text of every
proxy config into the results file, so the configs in this post cannot drift
from the ones that produced these numbers.

If you are streaming tokens through HAProxy on a stock config, that is where
your first 206 milliseconds went.

Top comments (9)

Collapse
 
raknaos profile image
Raknaos • Edited

The frames-per-read ratio is the part I keep thinking about. Most buffering debugging I've seen anchors on absolute latency deltas, which is exactly the number a noisy shared host destroys — and then you start blaming a proxy for a 40ms scheduler hiccup. We serve everything behind one reverse proxy and our streams are all small token-sized frames on a steady interval. Never once thought to test whether the proxy was coalescing them; we assumed nginx. What's the emitter you used — is the rig published somewhere? I'd like to point it at our config before I conclude we're fine.

Collapse
 
remdore profile image
Remdore

Not published, so the honest answer is that you cannot point it at your config today.

The emitter is about 180 lines of stdlib and the shape is more useful than the code: a plain http.server that sends SSE frames against a monotonic deadline computed from a fixed start, never sleep(interval) in a loop, and which records its own send timestamps and exposes them on an endpoint so you can audit whether it actually paced before believing anything it produced.

The client matters more than the emitter and is the part I got wrong first. Do not use urllib or requests: they buffer, and the buffering is the thing you are measuring. Raw socket, write the request by hand, TCP_NODELAY, Connection: close so the loop ends on EOF, and timestamp every recv() the moment it returns. Then count arrivals per read, not per frame. Two frames in one recv is one arrival carrying two events, and getting that backwards produces numbers that look entirely plausible and mean nothing.

Given your setup, a fleet of agents behind one proxy with small token-sized frames on a steady interval, you are in the exact regime where this shows up. Frame size is the first thing I would check, before touching any config. At roughly 60-byte frames HAProxy held first token 206ms; at roughly 1.1KB the same stock config gave 53ms. If you are already batching several tokens per frame you may have nothing to fix.

Collapse
 
remdore profile image
Remdore

Correcting myself: it is published now, mostly because you asked twice and the second time I had no good reason not to.

github.com/DimitrovK/sse-proxy-buf...

Everything that produced the numbers is in there, including the image digests and every proxy config as run, so the configs in the post cannot have drifted from the ones measured. Raw per-run rows too, not just the aggregates, so you can check the exclusions rather than take my word for them.

One thing before you point it at your own config: on macOS the pacing test fails and the self-test refuses to certify. That is the rig working, not a broken checkout, and I left the tolerance alone rather than relaxing it so a laptop passes. Develop wherever, measure on Linux. A busy host is how I got a 42ms finding that did not exist.

For your fleet I would still check frame size before touching any proxy config.

Collapse
 
codearea_shop_1f1def9b532 profile image
Codearea

This is a great reminder that sharing your accomplishments doesn't make you egotistical. It's okay to be proud of your work and celebrate your progress.
I also share my coding journey and achievements on Codecan.net, and I believe there's nothing wrong with that. We all have different paths, and someone's success doesn't take away from our own.

Keep sharing your wins. You deserve to feel good about what you've achieved!

Collapse
 
jo-do profile image
Jo Do

This is why "turn off proxy_buffering" might be the most repeated non-fix in streaming: the header is an nginx convention, and nginx was never the one holding your tokens. The frames-per-read metric is the smart part - a ratio that survives a noisy host and needs no baseline, which is exactly what you want when the claim is "this proxy batches." Bursts of five with zero gap, 206ms late, is a beautifully damning row. I have debugged a "streaming is slow" complaint that was really "someone put a buffering proxy in front of it," and the folk advice sent everyone looking at the wrong layer.

Collapse
 
remdore profile image
Remdore

Mis-aimed rather than a non-fix, I think, and the distinction took me an extra cell to find.

Every small-frame condition says the directive buys nothing: nginx and nginx with proxy_buffering off are identical at 1.02, both indistinguishable from no proxy. But that is a client reading promptly and a stream of about 2.4KB total, against nginx's default proxy_buffers of 8 by 4k or 8k. The buffers are never close to full, so the directive has nothing to do.

Push 328KB through a client that sleeps 200ms between reads and it separates: 53ms to first token with buffering on, 3ms with it off. Consistent too, median 53 and max 54 over ten runs, never once fast. So the advice is right in a regime nobody mentions and irrelevant in the one most people are actually in.

What it never did, in any of the nine conditions, is the thing it is famous for. Frames per read tracked the direct baseline everywhere. It cost latency; it did not turn the stream into one lump.

Collapse
 
p_o_26e854a54d851cd606f08 profile image
P O

the hold time difference is wild. i'd also compare sticky-session behavior and maxconn under the same load so the 206ms isn't just buffering hiding a backend stall.

Collapse
 
remdore profile image
Remdore

Neither applies here, and the reason is the topology rather than the tuning. The backend is one server, there is no maxconn anywhere in the config, and nothing sticky to be sticky about, because the client opens one connection at a time and sends Connection: close. No session to pin and no queue to saturate.

The backend-stall version of your question is the real one though, and it is the thing the rig is built to rule out. The upstream is a synthetic emitter that paces frames against a monotonic deadline and records its own send timestamps. After every run the harness asks the emitter whether it actually paced as instructed, and a run that drifted past tolerance is discarded rather than attributed to a proxy. So an upstream that stalled shows up as emitter drift and voids the cell. It cannot arrive as a 206ms proxy row.

The direct, unproxied baseline is measured in the same cell as the proxies: 2ms to first token, frames 50ms apart. Same emitter, same client, same host, same moment. A backend stall would have to be selective about which of six endpoints it stalled for.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.