Minimal SvelteKit + query.live reproduction showing that the
application/x-ndjson response from a live remote function silently
breaks behind any reverse proxy that buffers responses by default
(nginx being the obvious one) — and the one-line hooks.server.ts
workaround that fixes it.
A live demo is up at: https://query-live-repro.server.ollema.xyz
query.live returns application/x-ndjson, an open response that yields
newline-delimited JSON frames as the server-side async generator yields.
nginx's default proxy_pass config buffers upstream responses, so
the frames never reach the client; the connection just hangs. After
nginx hits proxy_read_timeout (default 60s) it closes the upstream and
flushes whatever (typically nothing) it had buffered, at which point
Chrome reports net::ERR_HTTP2_PROTOCOL_ERROR 200 (OK) and SvelteKit
flips live.connected to false.
The fix from the framework side is one header: X-Accel-Buffering: no,
which nginx interprets as "do not buffer this response."
Two pages, sharing the same watchCounter + bump remote functions and
the same in-memory counter:
/broken— no proxy hint, hangs forever behind nginx./fixed—X-Accel-Buffering: noset inhooks.server.tsfor this route, works as expected.
Open both in two tabs side-by-side: /fixed ticks live; /broken
doesn't, even when the bump comes from the other tab.
pnpm install
pnpm dev
# both /broken and /fixed work — vite dev does not buffer responsesThe bug only shows up once nginx (or any other default-buffering reverse proxy) is in front of the Node server.
Setup for the live demo: @sveltejs/adapter-node running in Docker,
exposed via nginx's default proxy_pass config. No special
configuration applied at the proxy.
- Page renders, but
live.connected === falsefrom the start. - Request to
/_app/remote/<hash>/watchCountersits pending for ~60s (matches nginx's defaultproxy_read_timeout). - Then it "completes" with the response below — and Chrome immediately
logs
net::ERR_HTTP2_PROTOCOL_ERROR 200 (OK).
Status Code: 200 OK
cache-control: private, no-store
content-type: application/x-ndjson
server: nginx
Pressing "bump" or "retry" reproduces the hang for the next request.
live.connected === trueimmediately.bumpticks the counter live.- Two
/fixedtabs stay in sync. - With one
/fixedand one/brokenopen,/fixedstill ticks on every bump — the workaround is per-response, scoped to that page.
The /fixed response carries the same Cache-Control: private, no-store
and Content-Type: application/x-ndjson as /broken, plus the hook
adds X-Accel-Buffering: no. nginx honors the header (and strips it
from the response sent to the client, so you won't see it in devtools —
its absence on /fixed responses is the expected sign that nginx acted
on it).
query.live correctly sets Cache-Control: private, no-store on the
response, which Cloudflare and some other proxies respect for both
caching and buffering decisions. nginx ignores Cache-Control for
proxy buffering (docs).
Its only opt-outs from the upstream side are:
proxy_buffering off;in the nginx config, or- an
X-Accel-Buffering: noresponse header from the upstream.
The hook in this repo uses the latter — it requires no changes on the proxy host.
@sveltejs/kit ^2.59.0@sveltejs/adapter-node ^5.5.4svelte ^5.55.5- node
22 - nginx