Environment
- phoenix.js from
main (mechanism unchanged since 1.8.x), LiveView 1.1.28
- Chrome 138; reproduced deliberately with DevTools network throttling, and observed in the wild on saturated last-mile links
Summary
After the primary transport opens, connectWithFallback re-arms the fallback timer around a health-check ping, and only the ping reply clears it (socket.js#L441-L457):
// if we've established primary, give the fallback a new period to attempt ping
clearTimeout(this.fallbackTimer)
this.fallbackTimer = setTimeout(fallback, fallbackThreshold)
this.ping(rtt => {
this.log("transport", "connected to primary after", rtt)
this.primaryPassedHealthCheck = true
clearTimeout(this.fallbackTimer)
})
The heartbeat reply arrives on the same websocket as everything else, so it is queued behind whatever the server is already sending. On a slow link, a LiveView join reply plus child joins can hold the downlink for longer than fallbackThreshold. The ping reply is not late because the connection is broken — it is late because the connection is busy. The timer then fires and fallback() tears down a healthy, actively-receiving websocket in the middle of its join.
Two things make it worse than a wasted transport swap:
- The interrupted join fails. In our case the LiveView join errors on the teardown and LiveView's reload failsafe reloads the page, which starts the same race again — a reload loop that never resolves on its own.
- The fallback gets memorized. Because the ping never returned,
primaryPassedHealthCheck is still false, so when longpoll opens it hits if(!this.primaryPassedHealthCheck){ this.storeSession(...) } and pins the session to longpoll — on a connection where websockets were working the whole time.
Reproduction
Any LiveView app whose join payload is larger than roughly fallbackThreshold × bandwidth, under a Chrome DevTools 3G-class throttle.
Ours, measured: 400 kbps down / 400 ms RTT (~50 KB/s, so ~250 KB of payload fits inside a 5 s window).
longPollFallbackMs: 5000 → the websocket is killed mid-join every time; the page reload-loops indefinitely.
longPollFallbackMs: 15000 → same page, same throttle, connects and stays up.
We shipped the threshold bump in our own app as a stopgap, but tuning a constant against a customer's bandwidth is not a fix — any threshold is wrong for a slow enough link or a large enough join payload.
Suggested direction (deferring to you on the design)
The health check exists to prove the connection works. Bytes actively arriving are that proof — arguably better proof than a ping reply, since they are the traffic the application actually cares about. So the ping-timeout path could treat "frames received since this connection opened" as passing, or restart the window while frames keep flowing, so a connection that is mid-download is never killed for a late ping.
The per-connection signal for this already exists in the PR for #6766 (#6768), which adds a connReceivedMessage flag reset in transportConnect and set in onConnMessage. That flag is exactly what this check would read, so the two fixes share a mechanism — worth deciding together, and we're happy to fold this into that PR or send a separate one, whichever you prefer.
Related, same family of enterprise / hostile-network failures: #6766, #6767, and PRs #6768, #6769.
We have a reproduction harness (throttled Playwright driver against a LiveView app with a large join payload) and are glad to share it or run a patch through it.
Filed on behalf of Rupert Deese by Claude, the AI engineering assistant at Gearflow.
Environment
main(mechanism unchanged since 1.8.x), LiveView 1.1.28Summary
After the primary transport opens,
connectWithFallbackre-arms the fallback timer around a health-check ping, and only the ping reply clears it (socket.js#L441-L457):The heartbeat reply arrives on the same websocket as everything else, so it is queued behind whatever the server is already sending. On a slow link, a LiveView join reply plus child joins can hold the downlink for longer than
fallbackThreshold. The ping reply is not late because the connection is broken — it is late because the connection is busy. The timer then fires andfallback()tears down a healthy, actively-receiving websocket in the middle of its join.Two things make it worse than a wasted transport swap:
primaryPassedHealthCheckis stillfalse, so when longpoll opens it hitsif(!this.primaryPassedHealthCheck){ this.storeSession(...) }and pins the session to longpoll — on a connection where websockets were working the whole time.Reproduction
Any LiveView app whose join payload is larger than roughly
fallbackThreshold × bandwidth, under a Chrome DevTools 3G-class throttle.Ours, measured: 400 kbps down / 400 ms RTT (~50 KB/s, so ~250 KB of payload fits inside a 5 s window).
longPollFallbackMs: 5000→ the websocket is killed mid-join every time; the page reload-loops indefinitely.longPollFallbackMs: 15000→ same page, same throttle, connects and stays up.We shipped the threshold bump in our own app as a stopgap, but tuning a constant against a customer's bandwidth is not a fix — any threshold is wrong for a slow enough link or a large enough join payload.
Suggested direction (deferring to you on the design)
The health check exists to prove the connection works. Bytes actively arriving are that proof — arguably better proof than a ping reply, since they are the traffic the application actually cares about. So the ping-timeout path could treat "frames received since this connection opened" as passing, or restart the window while frames keep flowing, so a connection that is mid-download is never killed for a late ping.
The per-connection signal for this already exists in the PR for #6766 (#6768), which adds a
connReceivedMessageflag reset intransportConnectand set inonConnMessage. That flag is exactly what this check would read, so the two fixes share a mechanism — worth deciding together, and we're happy to fold this into that PR or send a separate one, whichever you prefer.Related, same family of enterprise / hostile-network failures: #6766, #6767, and PRs #6768, #6769.
We have a reproduction harness (throttled Playwright driver against a LiveView app with a large join payload) and are glad to share it or run a patch through it.
Filed on behalf of Rupert Deese by Claude, the AI engineering assistant at Gearflow.