Skip to content

realtime: on wake-from-hidden the socket reconnects without re-auth, so channels rejoin with a stale JWT (root cause + runnable repro for #1732) #2613

Description

@fufu830118

Summary

When a hidden tab becomes visible again, @supabase/phoenix's visibilitychange listener reconnects the socket via this.teardown(() => this.connect())without running opts.beforeReconnect, which is exactly where realtime-js mounts its re-authentication (_reconnectAuth). The socket comes back up and every channel rejoins carrying the token that was cached before the tab went away: CHANNEL_ERROR: InvalidJWTToken: Token has expired N seconds ago.

We believe this is the missing root cause in #1732, which was closed as not_planned while still labelled repro needed. A deterministic reproduction (6 Node tests, ~2 s, no Supabase project, no waiting for a token to expire) is below.

Filed here rather than on supabase/phoenix because that repository has Issues disabled (GET /repos/supabase/phoenix"has_issues": false; it is a fork of phoenixframework/phoenix). One of the two changes belongs there as a PR; the other is in packages/core/realtime-js in this repo.

Still current: @supabase/realtime-js@2.112.3 (latest at time of writing) depends on @supabase/phoenix@0.4.5 (latest), and _reconnectAuth is unchanged in it. So the retry with latest label on #1732 does not resolve this.


Root cause: two reconnect paths, only one runs the hook

Both in assets/js/phoenix/socket.js, supabase/phoenix @ 94ab442 — current main, and the commit recorded as gitHead in published 0.4.5 (it is the "chore(main): release phoenix 0.4.5 (#50)" commit).

Path 1 — visibilitychange, hook skippedsocket.js#L114-L124

phxWindow.addEventListener("visibilitychange", () => {
  if(document.visibilityState === "hidden"){
    this.pageHidden = true
  } else {
    this.pageHidden = false
    // reconnect immediately
    if(!this.isConnected() && !this.closeWasClean){
      this.teardown(() => this.connect())      // <- beforeReconnect never runs
    }
  }
})

Path 2 — reconnectTimer, hook awaitedsocket.js#L170-L181

this.reconnectTimer = new Timer( () => {
  if(this.pageHidden){
    this.log("Not reconnecting as page is hidden!")
    this.teardown()
    return
  }

  this.teardown(async () => {
    if(opts.beforeReconnect) await opts.beforeReconnect()
    this.connect()
  })
}, this.reconnectAfterMs)

This reads as a missed call site, not a design decision

beforeReconnect does not exist in phoenixframework/phoenix at all (grep -c beforeReconnect on upstream main0; both reconnect sites there are the identical this.teardown(() => this.connect())). It was introduced in this fork by d8dd575"Add breaking changes to make realtime-js work with this package.", authored 2026-02-11.

That commit's diff for socket.js contains exactly one relevant hunk:

@@ -149,16 +153,22 @@
-      this.teardown(() => this.connect())
+
+      this.teardown(async () => {
+        if(opts.beforeReconnect) await opts.beforeReconnect()
+        this.connect()
+      })

At its parent commit the two call sites were byte-identical (socket.js L112 and L161). The commit rewrote the reconnectTimer one and left the visibilitychange one untouched — it is still untouched today.

The two paths are mutually exclusive, which is why this matters

While the page is hidden, reconnectTimer deliberately bails out and does not reschedule (#L171-L175; Timer.scheduleTimeout() in timer.js fires once and the callback does not re-arm it). Channel's rejoinTimer cannot help either — it is guarded by if(this.socket.isConnected()) (channel.js#L46-L48).

So a socket that dies while the tab is hidden can only come back through Path 1 — the path without the auth hook. This is also, we suspect, why #1732 stalled on repro needed: the natural manual test is to break the network with the tab in front of you, and that exercises Path 2, which works correctly.

Nothing downstream compensates

In realtime-js (master @ a249594):

  • The hook is mounted at RealtimeClient.ts#L850: result.beforeReconnect = this._reconnectAuth.bind(this).
  • RealtimeClient.connect()'s own guard if (this.accessToken && !this._authPromise) this._setAuthSafely('connect') (#L297-L308) is not reached, because Path 1 calls phoenix's Socket.prototype.connect() directly rather than RealtimeClient.connect().
  • The onOpen handler in _setupConnectionHandlers() (#L704-L711) only re-auths when !this.accessTokenValue; after the first successful auth that value is always truthy, so it resolves Promise.resolve() and refreshes nothing. It is not awaited before rejoin anyway — phoenix rejoins synchronously from socket.onOpen (channel.js#L50-L53) and the join payload carries socket.accessTokenValue (RealtimeChannel.ts#L415-L425).

Two gaps, two changes — and we measured which one closes what

Fix A — supabase/phoenix: run beforeReconnect on the visibilitychange path. (Please read "a caution for the phoenix patch" below before mirroring the reconnectTimer code literally.)

Fix B — realtime-js, _reconnectAuth (#L869-L875): today it only awaits an auth call that happens to already be in flight.

private async _reconnectAuth() {
  await this._waitForAuthIfNeeded()   // no-op when nothing is in flight
  if (!this.isConnected()) { this.connect() }
}

_waitForAuthIfNeeded() is if (this._authPromise) await this._authPromise (#L683-L687). When nothing is in flight it returns immediately and connect() fires _setAuthSafely() without awaiting it — so the join races the token again. What we tested was making it actively refresh:

 private async _reconnectAuth() {
   await this._waitForAuthIfNeeded()
+  if (!this._isManualToken()) { await this.setAuth() }
   if (!this.isConnected()) { this.connect() }
 }

That is the exact shape we measured — the _isManualToken() guard mirrors _setAuthSafely() (#L693-L700). We are not attached to the guard: on realtime-js ≤ 2.112.0 it would disable the fix for a default supabase-js client (see the precondition section), so dropping it — or revisiting the manual-token semantics — may be the better call. Your architecture, your choice.

Measured matrix

Run against the published packages; the tests assert the buggy behaviour, so a test failing means the behaviour is fixed. Patches applied to the installed node_modules with the repro's apply-fix-timeout.mjs (--phoenix / --realtime / --revert), which applies the patches exactly as proposed.

applied visibilitychange path does NOT call beforeReconnect visibilitychange path rejoins with the STALE token no in-flight setAuth: rejoin still races the token
nothing (as published) passes → bug present passes → bug present passes → bug present
Fix A only fails → fixed fails → fixed passes → still present
Fix A + Fix B fails → fixed fails → fixed fails → fixed

Fix B alone is not in the table because it is unreachable on this path by construction: without Fix A, beforeReconnect is never called at all.

Fix B is not, however, dependent on Fix A in general: on the reconnectTimer path — an ordinary network drop with the tab visible — Fix B alone already turns a stale rejoin into a fresh one (measured; that scenario is in the repro). Fix A is what extends the guarantee to the wake-from-hidden path, which is the one our production events take.

Reading: Fix A is necessary, and on its own it already fixes the case where a token refresh is in flight when the tab wakes. Fix B closes the remaining case — no refresh in flight at that instant — and that is the case our production data matches (see below).


Reproduction

https://github.com/fufu830118/supabase-realtime-visibilitychange-repro

Deterministic, ~2 seconds, no Supabase project, no server, no real device sleep, no waiting for an expiry:

git clone https://github.com/fufu830118/supabase-realtime-visibilitychange-repro
cd supabase-realtime-visibilitychange-repro
npm install
npm run test:all
✔ control: the reconnectTimer path DOES await beforeReconnect
✔ the visibilitychange path does NOT call beforeReconnect
✔ control: reconnectTimer path rejoins with the FRESH token
✔ visibilitychange path rejoins with the STALE token
✔ no in-flight setAuth: rejoin still races the token
✔ reconnectTimer path, no in-flight setAuth
ℹ tests 6   ℹ pass 6   ℹ fail 0   ℹ duration_ms 1920

Pinned to @supabase/phoenix@0.4.5 + @supabase/realtime-js@2.111.0; verified on Node v24.14.1.

Design notes, since these are the first things worth poking at:

  • Every assertion has a control. The control puts the socket in an identical state and lets the other reconnect path do the work, so the only variable is which path ran. You do not have to take our word for the mechanism.
  • The assertion is "which token was sent". A fake WebSocket records the access_token of every phx_join frame. There is no server, no clock skew and no real JWT validation involved — "the wrong token went out" is a mechanical fact.
  • What is simulated: visibility transitions (document.visibilityState + a dispatched visibilitychange), an abnormal 1006 close, and an accessToken callback that takes 150 ms to stand in for the refresh round-trip. The close has to bypass disconnect(): closeWasClean starts as true in the constructor (#L75-L79 — deliberately, so this listener will not connect a socket that was never connected), is set to false by transportConnect() (#L384) on every attempt, and is set back to true only by disconnect() (#L242) and replaceTransport() (#L199). So an already-connected socket dying abnormally satisfies the guard at #L120.
  • What is not: real device sleep, setTimeout throttling, JWT expiry itself. The heartbeat is parked at 10 minutes in scenario 2 — the heartbeat calls setAuth() and is one of the things that eventually self-heals this in production, so parking it isolates the reconnect rather than pretending the self-heal does not exist.
  • Packaging gotcha that cost us an hour: @supabase/realtime-js has no exports field, so Node loads its CJS build, which requires phoenix's priv/static/phoenix.cjs.js. Patching only assets/js/phoenix/socket.js or only phoenix.mjs changes nothing at runtime.

Every test asserts the buggy behaviour, so a test that starts failing is a defect that has been fixed. apply-fix.mjs / apply-fix-timeout.mjs in the same repo apply the candidate fixes to node_modules so you can watch each one flip.

Precondition we should disclose up front: _manuallySetToken

Our repro drives RealtimeClient directly and calls setAuth() with no argument, so _manuallySetToken stays false. That is deliberate, and it differs from a default supabase-js app on the version we run:

  • SupabaseClient._handleTokenChanged calls this.realtime.setAuth(token) with an explicit token (SupabaseClient.ts#L679).
  • In realtime-js ≤ 2.112.0, an explicit token wins in _performAuth: if (isManualToken) { this._manuallySetToken = true } else if (this.accessToken) { … = false }. The flag therefore latches to true and _setAuthSafely() early-returns for the rest of the session — including the heartbeat-driven call.
  • In realtime-js ≥ 2.112.1 the precedence is reversed — the presence of an accessToken callback wins (RealtimeClient.ts#L648-L658) — and since SupabaseClient always passes one (SupabaseClient.ts#L385), the flag stays false.

So the repro's configuration matches current default supabase-js behaviour, and it matches our own app (we call the no-argument setAuth() globally). It does not match a default app pinned to ≤ 2.112.0 — there, _setAuthSafely() is a no-op regardless of this issue. We are flagging it because if you reproduce on an old pin with a default client you may see a different failure shape and conclude nothing is wrong here. (The version boundary is from diffing the published bundles: 2.112.0 = manual-wins, 2.112.1 = callback-wins.)


Production evidence

Self-hosted Supabase (Docker), GOTRUE_JWT_EXP=3600, supabase-js / auth-js / realtime-js all 2.111.0, @supabase/phoenix@0.4.5. Errors collected by a self-hosted GlitchTip over 17 days.

measure value
CHANNEL_ERROR: InvalidJWTToken: Token has expired events 282
distinct affected users 65
events still inside the retention window, all of which kept breadcrumbs 262 / 262
…of those, events with a successful token refresh before the error 244 (93.1 %)
gap between that refresh (HTTP 200) and the CHANNEL_ERROR median 5.0 s (min 1.5 s)
events in the 08:00 and 09:00 local hours 26 %
adjacent event pairs with a refresh in between (self-heal) 33 / 34
observed time-to-recovery median 0 s, observed max 48 s

The load-bearing number is 244 of 262 (93.1 %), and it is a full census rather than a sample — every event still inside our retention window carries breadcrumbs, so we did not have to pick any. An event counts if any of its breadcrumbs satisfies all three of:

data.url  LIKE '%grant_type=refresh_token%'
data.status_code = 200
timestamp <= <the CHANNEL_ERROR's own timestamp>

Distribution of that gap: 3–6 s → 183 events, 6–15 s → 42, 15–60 s → 3, 1–3 s → 4, ≥ 60 s → 12, and 18 events had no prior successful refresh at all.

So in the overwhelming majority of cases the client was not failing to obtain a token — a fresh one had been sitting in auth-js for a median of five seconds. The token that went out on the wire was the stale one.

One trace: refresh returns 200 at 16:58:07.713; at 16:58:12.778 the channel reports expired 79503 seconds ago. 79,503 s back from that timestamp lands on the previous day's token.

The "expired by N seconds" distribution is single-peaked and continuous rather than bimodal, which fits one mechanism rather than two: auth-js stops its refresh ticker while the tab is hidden (GoTrueClient._onVisibilityChanged_stopAutoRefresh), so with a one-hour JWT any tab hidden longer than that wakes holding an expired token. The 08:00/09:00 concentration is people opening laptops in the morning.

Which scenario this is. Because a refresh had already completed before the failure, our events line up with the third repro scenario (nothing in flight for _waitForAuthIfNeeded() to await) rather than the second — i.e. Fix A alone would not have been enough for us. We cannot pin the exact interleaving from breadcrumbs, so treat that mapping as our best reading rather than a proof.

Severity, honestly: it self-heals. The user-visible symptom is "realtime is silently dead for up to about a minute after you wake your laptop", which is probably part of why it has been hard to pin down — by the time anyone looks, it works again.


A caution for whoever writes the phoenix patch

Please do not copy the reconnectTimer body verbatim into the visibilitychange listener. Reviewing the side effects turned up a system-level hazard that is not visible path-by-path:

  • Timer.scheduleTimeout() fires once and does not re-arm; the only two callers of reconnectTimer.scheduleTimeout() are onConnClose (#L553) and heartbeatTimeout (#L485), both of which need a live connection; and Channel's rejoinTimer never reconnects the socket. So if connect() is not called, nothing schedules another attempt — the socket is dead for good.
  • That wedge already exists on the reconnectTimer path today (a rejecting beforeReconnect skips connect()), and the only escape hatch from it is the visibilitychange listener, precisely because it ignores the hook. Wiring the hook in naively removes the last way out.
  • The realistic failure mode here is not rejection but a hook that never settles: on laptop wake the network is often not usable yet, and the chain _reconnectAuth → _waitForAuthIfNeeded → _performAuth → accessToken() → auth.getSession() → _refreshAccessToken → fetch has no AbortSignal and no timeout at any level. (auth-js's retryable bound caps the number of retries, not the duration of one hung fetch.) try/catch does not see a hang, and we measured what happens without a bound — see below.

So the patch should at minimum wrap the hook in try/catch and connect anyway, and re-check state after the await (pageHidden, closeWasClean, disconnecting, isConnected()) — a user can hide the tab again, or sign out, during that round-trip, and connect()'s own guard (if(this.conn && !this.disconnecting) return, #L262) does not stop a post-logout resurrection once teardown has nulled this.conn.

And the wait has to be bounded — that one is measured, not a preference. With an accessToken() callback that never settles and is already in flight when the tab wakes, three variants of the phoenix patch behave like this in the repro (realtime-js unpatched throughout, one shell invocation per row):

phoenix patch variant 1st wake 2nd wake outcome
none (published 0.4.5) sockets 1 → 2 2 reconnects — stale token, but alive
try/catch + state re-check, no bound sockets 1 → 1 1 wedged — never reconnects again
try/catch + Promise.race on the socket's own this.timeout sockets 1 → 2 2 reconnects

The unbounded middle row is worse than shipping nothing, and the second wake does not rescue it: setAuth() clears _authPromise only in a finally (RealtimeClient.ts#L514-L527), so a promise that never settles stays latched and every later _reconnectAuth awaits the same dead promise. The phoenix PR therefore ships the bound, using this.timeout (opts.timeout || DEFAULT_TIMEOUT, 10 s) so it introduces no new constant. Whether realtime-js should additionally stop latching a hung _authPromise is a separate call, and yours.

Two smaller side effects worth knowing: teardown()'s early return (#L497-L499) will start returning a Promise instead of undefined on this path (no caller inside the phoenix repository uses that return value, and realtime-js never calls Socket.teardown() itself — but it is a visible change for external callers); and routing this path through RealtimeClient.connect() means _setupConnectionHandlers() re-registers onOpen/onClose/onMessage on each wake, and phoenix appends those without dedup — a pre-existing leak on the timer path that this change gives a new trigger.


Relation to #1732 and to @create-signal's analysis

#1732 ("Access token not refreshed for realtime channels after being offline or in standby") describes the same user-facing symptom: opened 2024-01-10, 13 comments, 8 👍, closed not_planned by github-actions[bot] on 2026-05-10 with repro needed still attached. @filipecabaco asked for a reproduction and said "I've been trying to replicate it with no success 😓". None of the 13 comments mention visibilitychange, beforeReconnect, _reconnectAuth or phoenix — which we think is the whole story of why it could not be reproduced on demand.

@create-signal (2024-09-20) pointed out that the server sends error and phx_close per channel when the token expires, and that RealtimeChannel then removes itself from RealtimeClient.channels[] (_onClosesocket._remove), so a later setAuth() cannot help it. That is a real mechanism and our repro does not cover it — our fake server never sends phx_close, so we cannot rule it out, and the two can hold at the same time (his is about recovery after the rejection; ours is about which token goes out in the first place). If both are in play, fixing only one will leave a residue.

Would you consider reopening #1732, or treating this issue as its replacement? We are happy either way, and happy to move the discussion wherever is most useful.


Environment

  • @supabase/supabase-js, @supabase/realtime-js, @supabase/auth-js — all 2.111.0; @supabase/phoenix 0.4.5
  • Self-hosted Supabase (Docker, Postgres 17.6), GOTRUE_JWT_EXP=3600
  • Vue 3 SPA; desktop Chrome and Edge, mixed Windows and macOS; affected users are on office laptops (lid closed and reopened)
  • Repro verified on Node v24.14.1
  • Code references: supabase/phoenix @ 94ab442e74f5939cd277444cce94a56f1897f5d8 (current main, and the gitHead recorded in published 0.4.5), supabase/supabase-js @ a249594bc5790929ff090baa64f2d5bb3a40c286 (current master)
npx envinfo --system --npmPackages '{supabase,@supabase/*}' --binaries --browsers
  System:
    OS: Windows 11 10.0.26200
    CPU: (8) x64 Intel(R) Core(TM) Ultra 9 288V
    Memory: 9.08 GB / 31.54 GB
  Binaries:
    Node: 24.14.1
    npm: 11.11.0
  Browsers:
    Chrome: 151.0.7922.138
    Edge: Chromium (151.0.4129.86)
  npmPackages:
    @supabase/supabase-js: ^2.108.2 => 2.111.0

Package manager: npm.


Planned PRs (not yet opened)

  • supabase/phoenix — Fix A, plus the try/catch, the bounded wait and the post-await state re-check described above. The same try/catch belongs on the reconnectTimer path for the same reason, but that wedge is pre-existing, so the PR offers it rather than folding it in. Tests go in the existing describe("visibilitychange") block.
  • supabase/supabase-js, packages/core/realtime-js — Fix B in _reconnectAuth.

We will link them here once opened. Guidance on whether you want them split that way, or shaped differently, is very welcome before we do.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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