Skip to content

phoenix.js: the fallback health-check ping can kill a healthy websocket that is busy receiving #6770

Description

@rdeese

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions