SSE events delayed several seconds for iOS/WebKit clients over HTTP/2

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 in sjc, 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-encoding even with browser Accept-Encoding), and no HTTP/3 in play (no alt-svc advertised).

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

  1. 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.
  2. 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.
  3. 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.
  4. 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)?

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.