Mobile & Background-Tab Handling Permalink to this section
Part of Frontend Consumption & Client Patterns.
SSE connections are long-lived TCP streams. On desktop browsers with a focused tab, that works cleanly. On mobile β or any backgrounded tab β the operating system, browser, and network stack all conspire to tear the connection down silently, throttle JavaScript execution, or freeze the renderer process entirely. Unless you code defensively, clients miss events, reconnect storms hit your servers, and users see stale UIs.
This guide covers the mechanisms behind mobile tab suspension and visibility-driven throttling, implements a robust Page Visibility lifecycle manager, handles Last-Event-ID-based resumption on reconnect, and addresses battery and data-budget concerns that matter on real devices.
How the Browser and OS Disrupt SSE Connections Permalink to this section
The Page Visibility API Permalink to this section
The document.visibilityState property returns "visible" or "hidden". The browser fires visibilitychange on document whenever the tab is minimised, covered by another app, or the screen turns off. This is the primary hook for background detection β it is standardised (WHATWG HTML, βPage Visibilityβ section) and supported in every browser shipping EventSource.
document.addEventListener("visibilitychange", () => {
console.log(document.visibilityState); // "hidden" | "visible"
});
Mobile Suspension: Why Hidden Alone Isnβt Enough Permalink to this section
On desktop, a hidden tabβs JavaScript timer precision drops to 1 Hz (Budget Background Timeout), but the TCP connection persists. On mobile the picture is different:
| Platform | Behaviour when tab hidden | Typical threshold |
|---|---|---|
| iOS Safari (iPadOS/iPhone) | Renderer process suspended; TCP connections dropped | ~5 s of background |
| Android Chrome (Doze mode) | Alarms batched; network access revoked | Varies by OEM (5β30 s) |
| Android Firefox | Similar to Chrome but slightly more permissive | ~10β15 s |
| Desktop Chrome/Firefox/Edge | TCP persists; timer throttled to 1 Hz | No hard drop |
| Desktop Safari | TCP persists; slightly more aggressive than Chrome | No hard drop |
When the iOS renderer is suspended, the OS drops all open sockets silently. The EventSource object sees its readyState change to CONNECTING (1) and the browserβs built-in retry fires β but with no Last-Event-ID if you constructed the EventSource without the withCredentials or a manual ID store, potentially missing events emitted while the tab was frozen.
The Last-Event-ID Resume Mechanism Permalink to this section
Every SSE event with an id: field updates the browserβs βlast event ID stringβ. On reconnect (either the browserβs built-in retry or a manually constructed new EventSource), the browser sends:
GET /events HTTP/1.1
Last-Event-ID: 42
Cache-Control: no-cache
Your server receives this header and replays or fast-forwards from that ID. The spec guarantees the browser sends the header on every reconnect attempt, including after a mobile suspension β provided the browser process itself survived (iOS resumes the same session). See the Event ID & Retry Mechanism Design guide for server-side ID generation strategies.
Battery and Data Constraints Permalink to this section
Mobile OSes throttle network access to conserve battery. A persistent SSE connection polling heartbeat events every second drains battery faster than a connection sending events once a minute. Relevant signals available in JavaScript:
navigator.connection(Network Information API) βeffectiveType("4g","3g","2g","slow-2g"),saveDataboolean.navigator.getBattery()βchargingboolean,level(0β1).- Page Visibility β combine with the above to select a strategy.
Client-Side Implementation: Page Visibility Lifecycle Manager Permalink to this section
The pattern is to intentionally close the EventSource on hidden, persist the last received event ID locally, and open a new connection (passing the stored ID) on visible. This is cheaper than letting the connection die uncontrolled and more predictable than relying solely on the browserβs built-in reconnect.
// sse-visibility-manager.js
//
// Manages a single EventSource connection, pausing it when the tab is hidden
// and resuming it with Last-Event-ID when the tab becomes visible again.
export class SSEVisibilityManager {
#url;
#options;
#source = null;
#lastEventId = null;
#handlers = new Map(); // eventType β [callback, ...]
#retryDelay = 3000; // ms; back-off ceiling in #reconnect
#retryTimer = null;
#destroyed = false;
constructor(url, options = {}) {
this.#url = url;
// Allow caller to pass { withCredentials: true } or a seed lastEventId
this.#options = options;
if (options.lastEventId) this.#lastEventId = options.lastEventId;
this.#onVisibilityChange = this.#onVisibilityChange.bind(this);
document.addEventListener("visibilitychange", this.#onVisibilityChange);
if (document.visibilityState === "visible") {
this.#connect();
}
// If the page loads hidden (e.g. pre-rendered), defer until visible.
}
// ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Register a listener for a named event type (or "message" for unnamed). */
on(type, callback) {
if (!this.#handlers.has(type)) this.#handlers.set(type, []);
this.#handlers.get(type).push(callback);
// If the source is already open, attach immediately.
if (this.#source) this.#attachHandler(this.#source, type, callback);
return this;
}
/** The last event ID seen, suitable for persistence across page reloads. */
get lastEventId() { return this.#lastEventId; }
/** Permanently close the connection and remove all listeners. */
destroy() {
this.#destroyed = true;
document.removeEventListener("visibilitychange", this.#onVisibilityChange);
clearTimeout(this.#retryTimer);
this.#closeSource();
}
// ββ Private ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#connect() {
if (this.#destroyed) return;
this.#closeSource(); // defensive
// Build URL with lastEventId as query param as fallback for proxies that
// strip request headers (some CDNs strip Last-Event-ID).
const url = new URL(this.#url, location.href);
if (this.#lastEventId !== null) {
url.searchParams.set("lastEventId", this.#lastEventId);
}
const source = new EventSource(url.toString(), {
withCredentials: this.#options.withCredentials ?? false,
});
source.addEventListener("open", () => {
this.#retryDelay = 3000; // reset back-off on successful connection
});
// Re-attach all registered handlers
for (const [type, cbs] of this.#handlers) {
for (const cb of cbs) this.#attachHandler(source, type, cb);
}
source.addEventListener("error", () => {
// EventSource readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED
if (source.readyState === EventSource.CLOSED) {
// Browser gave up (e.g., 4xx). Schedule manual retry with back-off.
this.#scheduleReconnect();
}
// If CONNECTING, the browser is already retrying β let it.
});
this.#source = source;
}
#attachHandler(source, type, callback) {
const wrapped = (evt) => {
// Track last event id from the event object (browser sets this).
if (evt.lastEventId) this.#lastEventId = evt.lastEventId;
callback(evt);
};
source.addEventListener(type, wrapped);
// Store wrapped reference so we could remove it later if needed.
}
#closeSource() {
if (this.#source) {
this.#source.close();
this.#source = null;
}
}
#scheduleReconnect() {
clearTimeout(this.#retryTimer);
this.#retryTimer = setTimeout(() => {
this.#retryDelay = Math.min(this.#retryDelay * 2, 30_000); // cap at 30 s
if (document.visibilityState === "visible") this.#connect();
// If hidden, #onVisibilityChange will trigger connect when tab is shown.
}, this.#retryDelay);
}
#onVisibilityChange() {
if (document.visibilityState === "hidden") {
// Intentionally close to prevent silent OS-level TCP drop without ID tracking.
this.#closeSource();
} else {
// Tab is visible again. Open fresh connection with stored lastEventId.
clearTimeout(this.#retryTimer);
this.#connect();
}
}
}
Usage:
import { SSEVisibilityManager } from "./sse-visibility-manager.js";
// Restore last ID from sessionStorage across same-session navigations.
const savedId = sessionStorage.getItem("sse:lastEventId");
const mgr = new SSEVisibilityManager("/api/events", {
withCredentials: true,
lastEventId: savedId,
});
mgr.on("message", (evt) => {
sessionStorage.setItem("sse:lastEventId", evt.lastEventId);
console.log("payload:", JSON.parse(evt.data));
});
mgr.on("order-update", (evt) => {
store.dispatch(applyOrderUpdate(JSON.parse(evt.data)));
});
// Cleanup when the SPA unmounts the owning component.
window.addEventListener("pagehide", () => mgr.destroy());
Persisting Last-Event-ID Across Page Reloads Permalink to this section
sessionStorage survives tab restores on iOS Safari (the session is preserved when the renderer resumes). localStorage survives full reloads. Choose based on your ID scope:
| Storage | Survives suspend/resume | Survives reload | Scope |
|---|---|---|---|
sessionStorage |
Yes (same session) | Yes | Single tab |
localStorage |
Yes | Yes | All tabs, same origin |
| In-memory | Yes (if not GCβd) | No | Current instance only |
| Server-side (cookie/JWT claim) | Always | Always | All devices |
Battery and Data-Adaptive Strategy Permalink to this section
// adaptive-sse.js
// Downgrade SSE to a polling fallback on low battery or slow networks.
async function chooseStrategy() {
const conn = navigator.connection;
const saveData = conn?.saveData ?? false;
const slowNet = ["slow-2g", "2g"].includes(conn?.effectiveType);
let lowBattery = false;
if ("getBattery" in navigator) {
const battery = await navigator.getBattery();
lowBattery = !battery.charging && battery.level < 0.15; // below 15 %
}
if (saveData || slowNet || lowBattery) {
return "poll"; // fall back to long-poll or short-poll
}
return "sse";
}
async function initRealtime(url, onEvent) {
const strategy = await chooseStrategy();
if (strategy === "sse") {
const mgr = new SSEVisibilityManager(url, { withCredentials: true });
mgr.on("message", onEvent);
return () => mgr.destroy();
}
// Fallback: manual poll every 30 s
let active = true;
const poll = async () => {
if (!active) return;
const res = await fetch(url + "?poll=1");
const events = await res.json();
events.forEach(onEvent);
setTimeout(poll, 30_000);
};
poll();
return () => { active = false; };
}
On connection change events (e.g., user moves from Wi-Fi to cellular), re-evaluate:
navigator.connection?.addEventListener("change", async () => {
const strategy = await chooseStrategy();
// tear down current connection, re-init with new strategy
});
Server-Side: Handling Last-Event-ID and Replay Permalink to this section
For resumption to be lossless, the server must buffer recent events and replay them. A Node.js example with an in-memory ring buffer:
// server/sse-ring.mjs β Node.js 18+
import http from "node:http";
const RING_SIZE = 200; // keep last 200 events in memory
const ring = []; // { id, type, data }
let cursor = 0;
function pushEvent(type, data) {
const id = ++cursor;
const evt = { id, type, data: JSON.stringify(data) };
ring.push(evt);
if (ring.length > RING_SIZE) ring.shift();
return evt;
}
function eventsAfter(lastId) {
const n = Number(lastId);
return ring.filter((e) => e.id > n);
}
// Per-connection client set for fan-out
const clients = new Set();
http.createServer((req, res) => {
if (req.url?.startsWith("/events")) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", // disable nginx buffering
});
res.flushHeaders();
// Replay missed events if client sends Last-Event-ID header
// Also accept lastEventId query param for proxy-hostile environments.
const rawId =
req.headers["last-event-id"] ??
new URL(req.url, "http://x").searchParams.get("lastEventId");
if (rawId) {
for (const evt of eventsAfter(rawId)) {
res.write(`id: ${evt.id}\nevent: ${evt.type}\ndata: ${evt.data}\n\n`);
}
}
// Send a heartbeat comment every 25 s to keep the connection alive
// through NAT and mobile firewalls that drop idle TCP after ~30 s.
const hb = setInterval(() => res.write(": heartbeat\n\n"), 25_000);
clients.add(res);
req.on("close", () => {
clearInterval(hb);
clients.delete(res);
});
return;
}
// For testing: POST /push?type=foo with JSON body to broadcast
if (req.method === "POST" && req.url?.startsWith("/push")) {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
const type = new URL(req.url, "http://x").searchParams.get("type") ?? "message";
const evt = pushEvent(type, JSON.parse(body));
const line = `id: ${evt.id}\nevent: ${evt.type}\ndata: ${evt.data}\n\n`;
for (const client of clients) client.write(line);
res.writeHead(204).end();
});
return;
}
res.writeHead(404).end();
}).listen(3000);
The server-side ring means a client that was suspended for 30 seconds and missed 15 events will receive them all in burst on reconnect, without a gap. For durable replay at scale, see Broadcasting SSE Events with Redis Pub/Sub.
Edge Cases and Network Interference Permalink to this section
Proxy Buffering Permalink to this section
Nginx, Varnish, and many CDNs (Cloudflare, Fastly) buffer response bodies by default. A buffering proxy breaks SSE entirely: events accumulate in the proxy buffer and the client sees nothing. Set:
# nginx: disable proxy buffering for SSE endpoints
location /events {
proxy_pass http://upstream;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s; # hold the connection open
proxy_set_header X-Accel-Buffering "no";
}
For Cloudflare, the X-Accel-Buffering: no response header disables Edge buffering on Pro+ plans. Without it, Cloudflare may buffer the entire response.
See Buffer Management & Chunked Transfer Encoding for a full proxy mitigation guide.
Firewall and NAT TCP Timeout Permalink to this section
Corporate firewalls and mobile carrier NAT gateways silently drop TCP connections idle for 30β90 seconds. A 25-second heartbeat comment (: heartbeat\n\n) keeps the connection alive without triggering the browserβs built-in retry. The client ignores comment lines per the SSE spec.
iOS Safari Background Fetch Restrictions Permalink to this section
Since iOS 13, Safari kills renderer processes within ~5 seconds of backgrounding in low-power mode. Strategies:
- Intentional close on
hidden(theSSEVisibilityManagerabove): preferred, avoids zombie connections that consume server-side slots. - Service Worker background sync: use
navigator.serviceWorkerand Background Sync API to trigger a catch-up fetch on next foreground. Supported on Chrome Android, not iOS Safari. - Push Notifications fallback: for critical events, complement SSE with Web Push. The browser delivers the push even when the app is killed.
Android Doze Mode Permalink to this section
When a device enters Doze (screen off, not charging, stationary for ~1 hour), network access is revoked except during maintenance windows. Your SSE clientβs error event fires. The built-in EventSource retry will keep failing until the maintenance window opens. The SSEVisibilityManager approach is actually better here: the connection is already closed; when visibilitychange β visible fires (user picks up the phone), the manager reconnects immediately rather than waiting for the browserβs exponential back-off to align with a Doze window.
Last-Event-ID Stripped by Proxies Permalink to this section
Some proxies treat Last-Event-ID as an unknown header and strip it. Mitigate by:
- Sending the ID as a query parameter (
?lastEventId=42) as a fallback. - Encoding it in a custom header your proxy allowlists (
X-Last-Event-Id). - Persisting the ID in a cookie (set
HttpOnly: falseso JavaScript can read it).
Connection Limit: Six-Connection Ceiling Permalink to this section
HTTP/1.1 browsers cap connections per origin at six. Each open EventSource consumes one slot. If a user opens your app in six tabs, no tab can load new resources until one SSE connection closes. HTTP/2 multiplexes over one connection, eliminating this per-origin limit β it is the primary reason to serve SSE over HTTP/2. For connection pooling details.
Performance and Scale Considerations Permalink to this section
Server-Side Connection Count Permalink to this section
Each backgrounded mobile client that reconnects on resume maintains a persistent server connection. At 50,000 concurrent mobile users where 30% background the app simultaneously, that is 15,000 suspended-then-reconnecting connections firing within seconds of each other (e.g., after a major sports event ends and users switch back). Plan for burst reconnect storms:
- Jitter on reconnect: add
Math.random() * 2000ms to the reconnect delay inSSEVisibilityManager. - Connection draining: use a load balancer health check that starts rejecting new connections before a deploy, giving existing clients time to drain.
- Backpressure: apply a token-bucket rate limiter at the SSE endpoint. See Rate Limiting & Backpressure Handling.
Memory: The Ring Buffer Trade-off Permalink to this section
| Buffer size | Events held | Memory per node (avg 1 KB/event) | Max replay lag at 10 evt/s |
|---|---|---|---|
| 50 events | 50 | ~50 KB | 5 s |
| 200 events | 200 | ~200 KB | 20 s |
| 1 000 events | 1 000 | ~1 MB | 100 s |
| Redis stream (MAXLEN 10 000) | 10 000 | ~10 MB | ~16 min |
In-process rings are fast but donβt survive deploys. Redis Streams (XREAD + MAXLEN) survive restarts and work across nodes β see Scaling SSE Across Multiple Nodes with Redis.
CPU: Event Serialisation on Resume Burst Permalink to this section
On reconnect bursts, avoid serialising the ring buffer for each client individually. Pre-serialise the SSE wire format (id: ...\nevent: ...\ndata: ...\n\n) once when the event is pushed, then replay the pre-formatted strings. This cuts serialisation cost from O(clients Γ events) to O(events).
// Store pre-formatted SSE string instead of raw JSON
function pushEvent(type, data) {
const id = ++cursor;
const formatted = `id: ${id}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
ring.push({ id, formatted });
if (ring.length > RING_SIZE) ring.shift();
return id;
}
Validation and Debugging Permalink to this section
DevTools: Simulating Background Suspension Permalink to this section
Chrome DevTools β Application β Service Workers panel β β βBypass for networkβ. Then use the throttling controls:
- Open DevTools β Network tab β set throttling to βSlow 3Gβ.
- Open a second Chrome window, switch focus away β observe the SSE connection in the EventStream tab drop to
readyState = 0(CONNECTING) after a few seconds on the original window. - Switch focus back β confirm
readyState = 1(OPEN) and events resume.
For iOS simulation, use Safari β Develop β Simulate β Background App Refresh Disabled, then observe connection loss in the Web Inspector Network timeline.
Verifying Last-Event-ID on Reconnect Permalink to this section
# 1. Connect and collect some events
curl -N -H "Accept: text/event-stream" http://localhost:3000/events &
# 2. Simulate reconnect with a known last ID
curl -N \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: 5" \
http://localhost:3000/events
# Expect: events 6, 7, 8, ... appear immediately in the output
Structured Logging Checklist Permalink to this section
Add structured log fields to every SSE connection lifecycle event:
// Server: log resume requests
const rawId = req.headers["last-event-id"];
logger.info({
event: "sse_connect",
clientIp: req.socket.remoteAddress,
lastEventId: rawId ?? null,
replayCount: rawId ? eventsAfter(rawId).length : 0,
userAgent: req.headers["user-agent"],
});
Key metrics to track:
sse_reconnect_rateβ reconnections per minute per endpoint; spikes indicate mobile suspend cycles or proxy issues.sse_replay_depthβ distribution of how many events replayed; outliers indicate clients suspended for too long (ring buffer exhausted).sse_connection_duration_p50/p99β low P50 on mobile endpoints signals aggressive suspension.
β‘ Production Directives
- Always close
EventSourceonvisibilitychange β hiddenand storelastEventIdinsessionStorage; do not rely on the browser's passive reconnect to track event IDs across mobile suspensions. - Send a heartbeat SSE comment (
: heartbeat\n\n) every 25 seconds to prevent NAT and mobile carrier firewalls from silently dropping idle TCP connections. - Accept
Last-Event-IDas both a request header and alastEventIdquery parameter; proxy environments often strip non-standard headers. - Pre-format SSE wire strings at push time, not replay time, to avoid serialisation storms during bulk reconnect bursts.
- Apply exponential back-off with Β±2 s jitter on client-side reconnects to spread mobile resume storms across your connection pool.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Does the browser automatically send Last-Event-ID when it reconnects after mobile suspension?
Yes, if the browser process itself survived the suspension (iOS resumes the session rather than killing it). The browser's built-in EventSource reconnect always includes the Last-Event-ID header set to the last id: field received before the drop. However, if the renderer process was fully killed (less common but possible under memory pressure), the EventSource object is gone and no ID is sent on the next page load β which is why persisting the ID to sessionStorage and constructing a new EventSource on visibility restore is more reliable than relying solely on built-in reconnect.
Should I use a Service Worker to keep the SSE connection alive in the background?
No. Service Workers cannot hold open EventSource connections β the EventSource API is not available inside a Service Worker's global scope. You can use a Service Worker to intercept the SSE response via fetch and a ReadableStream, but managing long-lived connections this way is complex and iOS Safari's background Service Worker execution limits make it unreliable. The simpler approach is to close intentionally on hidden and reconnect with Last-Event-ID on visible.
How long can a mobile client be suspended before events are permanently lost?
That depends on your server-side ring buffer size and event rate. If your server emits 10 events per second and your ring holds 200 events, a suspension longer than 20 seconds will lose older events. Increase the ring size, reduce the event rate (batch low-priority updates), or move to a durable store like Redis Streams with a large MAXLEN. Critical applications should complement SSE with a REST endpoint that returns full state so the client can re-sync after a long suspension regardless of event ID.
What is the six-connection limit and does HTTP/2 fix it?
HTTP/1.1 browsers allow at most six simultaneous connections to the same origin. An EventSource occupies one connection permanently, so six open SSE tabs exhaust the limit and block all other network requests. HTTP/2 uses a single TCP connection with multiplexed streams, so many SSE connections share one socket β effectively eliminating the six-connection constraint. Serve your SSE endpoint (and the rest of the origin) over HTTP/2 to avoid this. Most modern CDNs and reverse proxies support HTTP/2 with minimal config.
How do I test mobile suspension without a physical device?
Use Chrome's "chrome://flags/#enable-throttle-display-none-and-visibility-hidden-cross-origin-iframes" for frame throttling, but for genuine suspension testing the most reliable approach is to use the built-in Network throttling in Chrome DevTools combined with switching focus away from the tab for >5 seconds. For iOS-specific behaviour, use a real device with Safari Web Inspector (via macOS Xcode's Simulator or a connected iPhone) and observe the Network timeline. Automated testing can mock document.visibilityState and fire synthetic visibilitychange events using Object.defineProperty in jsdom or Playwright's page.evaluate.