TL;DR: Small server-sent events (1–3KB) reach iOS browsers seconds late when served over fly-proxy’s HTTP/2. The same app is instant for desktop Chromium over Fly h2, instant for curl over Fly h2, instant for the same iPhone over Fly with ALPN restricted to http/1.1, and instant for the same iPhone over AWS ALB’s HTTP/2 with the identical container image. The evidence points at an interaction between fly-proxy’s h2 write path and WebKit’s h2 client behavior.
Setup
- App:
bucketz(Jetty/Pedestal, Clojure), single machine insjc, standard[http_service](edge h2 ⇄ backend HTTP/1.1 on port 8080). Server header at time of testing:Fly/6db84cd2 (2026-07-07). - The app serves a long-lived SSE stream (
text/event-stream): DOM patches of ~1.3–3KB per event, plus a heartbeat comment every 10s. - No compression on the stream (verified: no
content-encodingeven with browserAccept-Encoding), and no HTTP/3 in play (noalt-svcadvertised).
Symptom
On an iPhone (any iOS browser — all are WebKit), an SSE event that the server writes at time T renders at T + several seconds. Desktop Chromium on the same wifi renders the same event instantly. The delay looks bounded by the heartbeat interval — patches appear to sit somewhere until subsequent bytes (typically the next 10s heartbeat) push them through.
What we ruled out
- The app. Application logs (timestamped) show the event written to the response at tap-time; the client renders seconds later with no further server activity in between.
- fly-proxy delivery to a well-behaved client. Two simultaneous authenticated curl streams — one
--http1.1, one--http2— both received every event ~40–50ms after the triggering command, including after a 90s idle. Heartbeats arrived every 10.0s on the dot. So fly-proxy delivers promptly to curl’s h2 client; the failure needs WebKit’s h2 session specifically. - WebKit-over-h2 in general. We deployed the identical container image to AWS ECS behind an ALB (HTTPS listener,
routing.http2.enabled=true, ALB has no h3). Same iPhone, same wifi: instant updates over ALB h2. - Compression, HTTP/3, service workers, DNS/network — all eliminated along the way.
The single-variable A/B on Fly
The only change:
[http_service.tls_options]
alpn = ["http/1.1"]
With ALPN restricted to HTTP/1.1 — same app code, same phone, same wifi — iOS updates render instantly. (We haven’t flipped it back since; it’s the production workaround. Happy to re-enable h2 on a test app to demonstrate.)
So the failing combination is precisely: fly-proxy h2 edge ⨯ iOS WebKit client, with small streamed DATA payloads. Chromium’s h2 client is unaffected on Fly; WebKit is unaffected on ALB’s h2. That suggests something in how fly-proxy sizes, coalesces, or paces small h2 DATA frames (or responds to WebKit’s SETTINGS / flow-control profile, which differs from Chromium’s and curl’s) that WebKit’s stream consumer doesn’t surface until more bytes arrive.
Questions
- Is this a known issue or known WebKit interaction?
- Is there anything in fly-proxy’s h2 write path (frame coalescing, buffer thresholds, TLS record sizing, flow-control pacing) that could hold ~1–3KB DATA writes for clients with WebKit’s h2 session characteristics?
- Is there a supported way to influence h2 flush behavior per-app, short of disabling h2 via ALPN (our current workaround, which works fine for this app)?