Full debugging journey from browser-only page hangs to Cloudflare QUIC bug. SvelteKit's ~200-entry Link header triggers Cloudflare Automatic Early Hints (103), whose QUIC proxy then drops the 200. Fix: headers.delete('link') in hooks.server.ts. Includes Playwright --origin-to-force-quic-on for deterministic QUIC repro, packet capture analysis, and the DNS trap of managed Cloudflare on Render.
A SvelteKit app on Render hung indefinitely on full-page loads in Chrome and Brave. curl worked, headless Chrome worked, SPA navigation worked. Only real browsers doing SSR page loads hung. This is the full debugging path from symptom to root cause to fix, condensed from a multi-hour investigation.
The immediate trigger was a window.location.href assignment after a Magic Sync import — a full-page SSR navigation instead of SvelteKit's invalidate() pattern. Replacing it with await invalidate('space:data') + dispatch('complete') eliminated that specific trigger. But any page refresh, bookmark, or direct URL still hung.
Related: [[gtp_01kx6nnx2afyt9xx6y9txby4y5]]
The curl-works-but-browsers-hang pattern pointed to transport. Key insight: curl uses HTTP/2 over TCP; real browsers cache the alt-svc: h3=":443"; ma=86400 header and upgrade to QUIC on subsequent requests. Disabling QUIC in chrome://flags/#enable-quic confirmed it instantly.
Headless browsers start fresh with no cached alt-svc, so they default to HTTP/2 and can't reproduce QUIC bugs. The fix: Chromium's --origin-to-force-quic-on flag forces QUIC from the first request.
const browser = await chromium.launch({
args: ['--enable-quic', '--origin-to-force-quic-on=example.com:443', '--quic-version=h3'],
});Result: 20/20 TIMEOUT over QUIC, 5/5 ok over HTTP/2. 100% deterministic.
Related: [[gtp_01kx7azmhve79a1jc5bwhwmzk4]]
Using tcpdump with a TLS keylog file, then analyzing with tshark:
0.036s Server sends 103 Early Hints (HEADERS frame on QUIC stream 0)
0.038s Client ACKs
... 15 SECONDS OF SILENCE ...
15.03s Client times outThe server sends the 103 but never sends the 200. The QUIC connection stays alive (ACKs flow), but HTTP/3 stream 0 gets no more data.
SvelteKit does NOT call writeEarlyHints(). It sets a Link response header with ~200 preload entries (CSS + JS modulepreload). Cloudflare's Automatic Early Hints feature reads this header and generates the 103.
Confirmed by hitting the Render origin directly (.onrender.com) — no 103, just the Link header on the 200.
Our Cloudflare zone has the CNAME set to cf-proxied:false (grey cloud). We toggled HTTP/3 off in our dashboard — no effect. Because the Cloudflare in the response path is Render's managed Cloudflare, not ours. We have no access to Render's Cloudflare dashboard.
One line in hooks.server.ts:
headers.delete('link');No Link header means Cloudflare has nothing to generate 103 from. The preload hints still reach the browser via <link> tags in the HTML body, which SvelteKit always injects. The Link header was redundant.
Result: 3/3 success over QUIC on both staging and production, 16-665ms.
Related: [[gtp_01kx7k7gayev1bdbsksy6ty558]]
curl works but browsers hang = suspect the transport layer. curl typically lacks HTTP/3; browsers upgrade via cached alt-svc.
Use --origin-to-force-quic-on for deterministic QUIC repro. Headless browsers never reproduce QUIC bugs without it.
Cloudflare strips origin Alt-Svc headers. You cannot set Alt-Svc: clear from your app. The edge controls it.
Managed Cloudflare (Render, etc.) means you don't control the edge. Your own Cloudflare zone toggles are irrelevant if the CNAME is cf-proxied:false.
Cloudflare's QUIC proxy has a bug with 103 Early Hints. It sends the 103 but drops the 200. This affects any origin that sets a Link header behind Render's Cloudflare.
SvelteKit's Link header is massive and redundant. ~200 entries for a complex page, duplicated as HTML link tags. Known issue (sveltejs/kit#6519), fix proposed for v3 (#15939). Strip it now.