Describe the bug
A pendingHeartbeatRef armed on one socket can survive a disconnect onto the next, healthy
connection, whose first heartbeat then takes the timeout branch and force-closes it with
"heartbeat timeout" without ever sending a frame. Since the phoenix integration (#2119), the
library handles this correctly for the default configuration — phoenix's resetHeartbeat(), called
from onConnOpen(), clears the stale ref on every open:
// phoenix.cjs.js — onConnOpen(), lines 1577-1587
onConnOpen() {
...
this.establishedConnections++; // :1581
this.flushSendBuffer(); // :1582
if (this.autoSendHeartbeat) { // :1584 — THE GATE
this.resetHeartbeat(); // :1585 — only place pendingHeartbeatRef is cleared on open
}
this.triggerStateCallbacks("open"); // :1587
}
// resetHeartbeat() — lines 1609-1614
resetHeartbeat() {
if (this.conn && this.conn.skipHeartbeat) return;
this.pendingHeartbeatRef = null; // the clear
this.clearHeartbeats();
this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs);
}
The clear only runs when this.autoSendHeartbeat is true. RealtimeClient sets
autoSendHeartbeat = !this.worker (RealtimeClient.js:684, current through 2.112.3 at line 686) —
i.e. it is false for any client configured with worker: true. Worker mode exists specifically so
the heartbeat timer isn't throttled by background-tab timer clamping, so it's a normal production
configuration, not an edge case. For those clients, onConnOpen() never calls resetHeartbeat(), so
a pendingHeartbeatRef stranded by a drop-with-beat-in-flight survives onto the next, healthy
connection exactly as before the fix — that connection's first heartbeat sees a non-null
pendingHeartbeatRef, takes the timeout branch, and force-closes itself with "heartbeat timeout"
having sent zero frames.
So the fix conflates two responsibilities that resetHeartbeat() performs together: clearing stale
heartbeat state (correct to do unconditionally on every open) and arming the auto-send timer (correct
to gate on autoSendHeartbeat, since worker-mode clients drive heartbeats from the worker instead).
Gating both together on autoSendHeartbeat means worker-mode clients get neither.
To Reproduce
Standalone — no app code, only @supabase/realtime-js (which brings in @supabase/phoenix). Fakes a
WebSocket transport and a Worker so the sequence runs deterministically without real timers or a
network.
// repro.ts — run with `npx tsx repro.ts` (or transpile/run any way you like)
import assert from 'node:assert/strict'
import { RealtimeClient } from '@supabase/realtime-js'
// ---- Fake Worker: records the latest instance; ticks only on command (no real setInterval) ----
class FakeWorker {
onmessage: ((e: { data: { event: string } }) => void) | null = null
onerror: ((e: { message?: string }) => void) | null = null
terminated = false
constructor(_url: string) {}
postMessage(_m: unknown) {}
terminate() { this.terminated = true }
tick() { if (this.onmessage && !this.terminated) this.onmessage({ data: { event: 'keepAlive' } }) }
}
let lastWorker: FakeWorker
;(globalThis as any).Worker = class extends FakeWorker {
constructor(url: string) { super(url); lastWorker = this } // library terminates+recreates per connection
}
if (typeof URL.createObjectURL !== 'function') (URL as any).createObjectURL = () => 'blob:fake'
// ---- Fake WebSocket transport ----
class FakeWS {
readyState = 0
sent: string[] = []
closedWith: { code: number; reason: string } | null = null
binaryType = 'arraybuffer'
onopen: ((e: object) => void) | null = null
onclose: ((e: object) => void) | null = null
constructor(public url: string) { instances.push(this) }
send(d: unknown) { this.sent.push(String(d)) }
close(code = 1000, reason = '') {
this.closedWith = { code, reason }
this.readyState = 3
queueMicrotask(() => this.onclose?.({ code, reason, wasClean: true }))
}
}
const instances: FakeWS[] = []
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function main() {
const statuses: string[] = []
const client = new RealtimeClient('wss://repro.invalid/realtime/v1', {
params: { apikey: 'repro-key' },
transport: FakeWS as any,
worker: true, // production configuration — the gate this bug is about
heartbeatIntervalMs: 60_000, // no auto-fire; beats are driven by hand below
reconnectAfterMs: () => 10,
heartbeatCallback: (s) => statuses.push(s),
})
const beat = () => lastWorker.tick() // worker mode: a beat is driven by the worker's keepAlive tick
client.connect()
await wait(30)
const A = instances[0]
A.readyState = 1
A.onopen?.({})
await wait(50)
beat() // arm a heartbeat on A
await wait(30)
assert.ok(A.sent.some((f) => f.includes('heartbeat')), 'expected a heartbeat frame on A')
// Drop A with the beat in flight — a Wi-Fi roam, laptop sleep, or server-side close all do this.
A.readyState = 3
A.onclose?.({ code: 1006, reason: 'network drop', wasClean: false })
let B: FakeWS | undefined
for (let i = 0; i < 200 && !B; i++) { await wait(10); B = instances[1] }
if (!B) throw new Error('client never reconnected')
B.readyState = 1
B.onopen?.({}) // <-- onConnOpen() runs here; autoSendHeartbeat is false, so no clear happens
await wait(50)
beat() // first beat on the NEW, healthy socket
await wait(30)
console.log('statuses:', statuses)
console.log('frames on B:', B.sent.filter((f) => f.includes('heartbeat')).length)
console.log('B closed with:', B.closedWith)
}
main()
Observed vs Expected
|
worker mode (observed, current behavior) |
non-worker mode (for contrast — already correct) |
statuses |
['sent', 'timeout'] |
['sent', 'sent'] |
| frames on B |
0 |
1 |
| B closed? |
{ reason: 'heartbeat timeout' } |
not closed |
Expected: worker mode should match the non-worker column — ['sent', 'sent'], 1 frame on B, B stays
open. The stranded ref from A should have been cleared when B opened, exactly as it is for
worker: false.
Proposed fix
Split resetHeartbeat()'s two responsibilities in onConnOpen() instead of gating both together —
hoist the ref clear above the gate, leave only the timer-arming call inside it:
onConnOpen() {
...
this.establishedConnections++;
this.flushSendBuffer();
this.pendingHeartbeatRef = null; // moved out of the gate — unconditional on every open
this.heartbeatSentAt = null; // moved out of the gate — unconditional on every open
if (this.autoSendHeartbeat) {
this.resetHeartbeat(); // now only (re-)arms the auto-send timer
}
this.triggerStateCallbacks("open");
}
(resetHeartbeat() itself keeps clearing the ref when it runs — that's redundant but harmless for the
autoSendHeartbeat: true path, which already worked correctly.) This costs nothing for non-worker
clients and fixes worker-mode clients, whose external heartbeat driver (a Worker's setInterval, in
this app's case) still needs pendingHeartbeatRef cleared on open even though the library isn't the
one arming the next send.
To be explicit about what was already correctly fixed: non-worker mode was correctly fixed by
resetHeartbeat() in the 0.4.x line — the bug reported here is scoped entirely to
autoSendHeartbeat === false (i.e. worker: true).
System information
@supabase/realtime-js 2.108.2 (also affects 2.112.3, the latest checked — identical gate, one line
later)
@supabase/phoenix 0.4.5 (the vendored dependency where the gate actually lives —
onConnOpen/resetHeartbeat in priv/static/phoenix.cjs.js)
- Client options:
{ worker: true, heartbeatIntervalMs: 25000, heartbeatCallback }
- Repro requires no network and no browser — pure
RealtimeClient + fake transport/Worker, as above
Describe the bug
A
pendingHeartbeatRefarmed on one socket can survive a disconnect onto the next, healthyconnection, whose first heartbeat then takes the timeout branch and force-closes it with
"heartbeat timeout"without ever sending a frame. Since the phoenix integration (#2119), thelibrary handles this correctly for the default configuration — phoenix's
resetHeartbeat(), calledfrom
onConnOpen(), clears the stale ref on every open:The clear only runs when
this.autoSendHeartbeatis true.RealtimeClientsetsautoSendHeartbeat = !this.worker(RealtimeClient.js:684, current through 2.112.3 at line 686) —i.e. it is false for any client configured with
worker: true. Worker mode exists specifically sothe heartbeat timer isn't throttled by background-tab timer clamping, so it's a normal production
configuration, not an edge case. For those clients,
onConnOpen()never callsresetHeartbeat(), soa
pendingHeartbeatRefstranded by a drop-with-beat-in-flight survives onto the next, healthyconnection exactly as before the fix — that connection's first heartbeat sees a non-null
pendingHeartbeatRef, takes the timeout branch, and force-closes itself with"heartbeat timeout"having sent zero frames.
So the fix conflates two responsibilities that
resetHeartbeat()performs together: clearing staleheartbeat state (correct to do unconditionally on every open) and arming the auto-send timer (correct
to gate on
autoSendHeartbeat, since worker-mode clients drive heartbeats from the worker instead).Gating both together on
autoSendHeartbeatmeans worker-mode clients get neither.To Reproduce
Standalone — no app code, only
@supabase/realtime-js(which brings in@supabase/phoenix). Fakes aWebSocket transport and a Worker so the sequence runs deterministically without real timers or a
network.
Observed vs Expected
statuses['sent', 'timeout']['sent', 'sent']{ reason: 'heartbeat timeout' }Expected: worker mode should match the non-worker column —
['sent', 'sent'], 1 frame on B, B staysopen. The stranded ref from A should have been cleared when B opened, exactly as it is for
worker: false.Proposed fix
Split
resetHeartbeat()'s two responsibilities inonConnOpen()instead of gating both together —hoist the ref clear above the gate, leave only the timer-arming call inside it:
(
resetHeartbeat()itself keeps clearing the ref when it runs — that's redundant but harmless for theautoSendHeartbeat: truepath, which already worked correctly.) This costs nothing for non-workerclients and fixes worker-mode clients, whose external heartbeat driver (a Worker's
setInterval, inthis app's case) still needs
pendingHeartbeatRefcleared on open even though the library isn't theone arming the next send.
To be explicit about what was already correctly fixed: non-worker mode was correctly fixed by
resetHeartbeat()in the 0.4.x line — the bug reported here is scoped entirely toautoSendHeartbeat === false(i.e.worker: true).System information
@supabase/realtime-js2.108.2 (also affects 2.112.3, the latest checked — identical gate, one linelater)
@supabase/phoenix0.4.5 (the vendored dependency where the gate actually lives —onConnOpen/resetHeartbeatinpriv/static/phoenix.cjs.js){ worker: true, heartbeatIntervalMs: 25000, heartbeatCallback }RealtimeClient+ fake transport/Worker, as above