Summary
On iOS Safari, flarum/realtime stops delivering events to the client after the tab has been backgrounded or the device has been idle. There are actually two distinct failure modes that both result in "no realtime updates after returning to the forum," and a correct fix needs to handle both.
Affected versions
- Reproduced on
flarum/realtime ^2.0@beta running against Flarum v2.0.0-beta.8 (and expected to affect all subsequent 2.x releases — the relevant code path hasn't changed).
- Desktop browsers are unaffected.
Scenario 1 — silent socket death: no events arrive after returning
- Sign in on an iPhone and open the forum (index page or a discussion). With
debug: true in config, confirm in the console that the websocket is connected (Pusher : State changed : connecting -> connected).
- Switch to another app (or let the screen turn off) for ~10 s or more.
- Switch back to Safari.
- From a different device, create a new discussion or post a reply.
Observed: the new item never arrives on the iPhone — no badge, no toast, no streamed-in post. Manual reload is the only recovery.
Expected: the event arrives within a moment or two, same as on desktop.
Scenario 2 — missed-while-away: events posted during the hidden period are lost
- Same setup — signed-in iPhone with the forum open.
- Switch to another app for ~10 s or more.
- While still away, have another user post something (new discussion or reply in a discussion the iPhone user is viewing).
- Return to Safari.
Observed: even if the socket recovers (see scenario 1), the event posted during the hidden window is silently lost. Pusher is fire-and-forget with no server-side replay, so any events that fired while the socket was dead are gone. The UI shows nothing new; only a manual reload reveals the content.
Expected: on return, the UI catches up on whatever was missed — at minimum by refreshing the currently-visible list.
Root cause
Two independent iOS Safari behaviors compound here:
-
iOS Safari silently closes WebSocket connections when a tab loses focus or the device sleeps, without firing onclose on the client. The connection appears alive to JS but is actually dead. Pusher-js's built-in recovery relies on its ping cadence to notice; iOS throttles timers on hidden tabs, so that heartbeat often doesn't run, and when the tab becomes visible again the client still believes it's subscribed. Relevant WebKit reports and cross-library reproductions: WebKit #228296, WebKit #247943, socket.io #2924, pusher-js #317, pusher-js #347.
-
iOS Safari bfcaches pages on app-switch. When the user switches to another app and switches back, iOS frequently restores the page from bfcache — which does NOT fire visibilitychange. Instead it fires pageshow with event.persisted === true. A handler wired only to visibilitychange will miss this common return path. WebKit bfcache notes.
-
Pusher has no server-side message buffering. Events delivered while the socket was dead are dropped on the floor; they are not resent when the socket reconnects. So even a perfect reconnect recovers scenario 1 but leaves scenario 2 broken — the client needs to explicitly refresh visible lists to catch up.
The extension's current client-side code has neither a visibility/pageshow-driven reconnect nor a post-reconnect catch-up refresh:
// extensions/realtime/js/src/forum/extend/Application.ts (current)
app.websocket = new Pusher(key, {
channelAuthorization: { endpoint: ..., transport: 'ajax' },
wsHost, wsPort, wssPort,
enabledTransports: ['wss', 'ws'],
forceTLS: secure,
});
// ... channel subscriptions, bindings — no visibility/pageshow handlers anywhere
grep -r "visibilitychange\|pageshow" extensions/realtime/js/src framework/core/js/src returns zero matches.
Suggested fix
Add a visibilitychange + pageshow driven forceReconnect() that:
- Fires after a non-trivial hide duration (threshold: >5 s on
visibilitychange), OR on bfcache restore (pageshow with persisted: true).
- Tears down the socket explicitly with
disconnect() + connect() (a small setTimeout gap between them avoids a "connecting zombie" race in pusher-js where a connect() called while the teardown is still in flight no-ops).
- Refreshes the visible
PaginatedListState after the new connection reports 'connected' — specifically app.discussions.refresh() — so scenario 2 is covered. The refresh must be gated on the 'connected' event, not fired immediately after connect(); firing immediately triggers a Mithril redraw that race-conditions with pusher-js's channel resubscription and leaves the client receiving no further push events.
Sketch (TypeScript):
const RECONNECT_HIDDEN_THRESHOLD_MS = 5_000;
let hiddenSince: number | null = null;
const forceReconnect = (reason: string): void => {
if (!app.websocket) return;
const connection = (app.websocket as any).connection;
const onReconnected = (): void => {
connection.unbind('connected', onReconnected);
(app as any).discussions?.refresh?.().catch((e: unknown) => {
console.warn('[flarum-realtime] discussions.refresh threw', e);
});
};
connection?.bind('connected', onReconnected);
try { app.websocket.disconnect(); } catch {}
setTimeout(() => {
try { app.websocket?.connect(); } catch {}
}, 100);
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') { hiddenSince = Date.now(); return; }
if (hiddenSince === null) return;
const wasHiddenFor = Date.now() - hiddenSince;
hiddenSince = null;
if (wasHiddenFor > RECONNECT_HIDDEN_THRESHOLD_MS) forceReconnect(`visibilitychange after ${wasHiddenFor}ms`);
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) forceReconnect('pageshow-bfcache');
});
Design notes:
- Why the 5 s threshold — avoids churning the socket on rapid app-switches; in practice iOS doesn't kill a socket that quickly.
- Why
pageshow is needed in addition to visibilitychange — app-switch return on iOS frequently uses bfcache and does not fire visibilitychange.
- Why a setTimeout between
disconnect() and connect() — pusher-js's internal state machine doesn't like a connect() while its teardown is still mid-flight; 100ms is plenty.
- Why refresh must wait for
'connected' — I tried firing app.discussions.refresh() immediately after connect() in an earlier iteration; the resulting Mithril redraw race-conditioned with pusher-js's channel resubscription on the new socket, leaving the client receiving no further push events (scenario 1 regressed). Binding refresh to the 'connected' event on the connection object fires it only after subscribeAll() has run, which is race-free.
- No
enabledTransports change — leaving ['wss', 'ws'] alone; HTTP polling fallback would mask the actual reconnect and isn't needed once visibility-driven recovery is in place.
Environment
- Flarum
v2.0.0-beta.8 + flarum/realtime ^2.0@beta.
pusher-js ^7.6.0.
- Affected: iPhone, mobile Safari (symptom matches the wider iOS WebSocket pattern — not specific to a single iOS version).
- Unaffected: desktop Chrome, Firefox, Safari.
- Custom extensions on the reporting forum don't interact with the websocket; verified not involved.
PR in the same area coming shortly.
Summary
On iOS Safari,
flarum/realtimestops delivering events to the client after the tab has been backgrounded or the device has been idle. There are actually two distinct failure modes that both result in "no realtime updates after returning to the forum," and a correct fix needs to handle both.Affected versions
flarum/realtime^2.0@betarunning against Flarumv2.0.0-beta.8(and expected to affect all subsequent 2.x releases — the relevant code path hasn't changed).Scenario 1 — silent socket death: no events arrive after returning
debug: truein config, confirm in the console that the websocket is connected (Pusher : State changed : connecting -> connected).Observed: the new item never arrives on the iPhone — no badge, no toast, no streamed-in post. Manual reload is the only recovery.
Expected: the event arrives within a moment or two, same as on desktop.
Scenario 2 — missed-while-away: events posted during the hidden period are lost
Observed: even if the socket recovers (see scenario 1), the event posted during the hidden window is silently lost. Pusher is fire-and-forget with no server-side replay, so any events that fired while the socket was dead are gone. The UI shows nothing new; only a manual reload reveals the content.
Expected: on return, the UI catches up on whatever was missed — at minimum by refreshing the currently-visible list.
Root cause
Two independent iOS Safari behaviors compound here:
iOS Safari silently closes WebSocket connections when a tab loses focus or the device sleeps, without firing
oncloseon the client. The connection appears alive to JS but is actually dead. Pusher-js's built-in recovery relies on its ping cadence to notice; iOS throttles timers on hidden tabs, so that heartbeat often doesn't run, and when the tab becomes visible again the client still believes it's subscribed. Relevant WebKit reports and cross-library reproductions: WebKit #228296, WebKit #247943, socket.io #2924, pusher-js #317, pusher-js #347.iOS Safari bfcaches pages on app-switch. When the user switches to another app and switches back, iOS frequently restores the page from bfcache — which does NOT fire
visibilitychange. Instead it firespageshowwithevent.persisted === true. A handler wired only tovisibilitychangewill miss this common return path. WebKit bfcache notes.Pusher has no server-side message buffering. Events delivered while the socket was dead are dropped on the floor; they are not resent when the socket reconnects. So even a perfect reconnect recovers scenario 1 but leaves scenario 2 broken — the client needs to explicitly refresh visible lists to catch up.
The extension's current client-side code has neither a visibility/pageshow-driven reconnect nor a post-reconnect catch-up refresh:
grep -r "visibilitychange\|pageshow" extensions/realtime/js/src framework/core/js/srcreturns zero matches.Suggested fix
Add a
visibilitychange+pageshowdrivenforceReconnect()that:visibilitychange), OR on bfcache restore (pageshowwithpersisted: true).disconnect()+connect()(a small setTimeout gap between them avoids a "connecting zombie" race in pusher-js where aconnect()called while the teardown is still in flight no-ops).PaginatedListStateafter the new connection reports'connected'— specificallyapp.discussions.refresh()— so scenario 2 is covered. The refresh must be gated on the'connected'event, not fired immediately afterconnect(); firing immediately triggers a Mithril redraw that race-conditions with pusher-js's channel resubscription and leaves the client receiving no further push events.Sketch (TypeScript):
Design notes:
pageshowis needed in addition tovisibilitychange— app-switch return on iOS frequently uses bfcache and does not firevisibilitychange.disconnect()andconnect()— pusher-js's internal state machine doesn't like aconnect()while its teardown is still mid-flight; 100ms is plenty.'connected'— I tried firingapp.discussions.refresh()immediately afterconnect()in an earlier iteration; the resulting Mithril redraw race-conditioned with pusher-js's channel resubscription on the new socket, leaving the client receiving no further push events (scenario 1 regressed). Binding refresh to the'connected'event on the connection object fires it only aftersubscribeAll()has run, which is race-free.enabledTransportschange — leaving['wss', 'ws']alone; HTTP polling fallback would mask the actual reconnect and isn't needed once visibility-driven recovery is in place.Environment
v2.0.0-beta.8+flarum/realtime ^2.0@beta.pusher-js ^7.6.0.PR in the same area coming shortly.