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.

SSE lifecycle across tab visibility and mobile suspension states State machine showing EventSource transitions: Active β†’ Hidden β†’ Suspended β†’ Reconnecting β†’ Active, with Page Visibility API and Last-Event-ID annotations. ACTIVE streaming events HIDDEN tab backgrounded SUSPENDED OS froze renderer RECONNECTING Last-Event-ID sent PAUSED EventSource closed visibilitychange hidden OS timer (~5 s mobile) tab visible again stream resumed, no missed events close() intentional new EventSource + lastEventId Streaming Throttled Suspended Intentionally paused Reconnecting (Last-Event-ID)
SSE connection state machine across Page Visibility and mobile OS suspension lifecycle events.

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"), saveData boolean.
  • navigator.getBattery() β€” charging boolean, 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 (the SSEVisibilityManager above): preferred, avoids zombie connections that consume server-side slots.
  • Service Worker background sync: use navigator.serviceWorker and 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:

  1. Sending the ID as a query parameter (?lastEventId=42) as a fallback.
  2. Encoding it in a custom header your proxy allowlists (X-Last-Event-Id).
  3. Persisting the ID in a cookie (set HttpOnly: false so 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() * 2000 ms to the reconnect delay in SSEVisibilityManager.
  • 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:

  1. Open DevTools β†’ Network tab β†’ set throttling to β€œSlow 3G”.
  2. 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.
  3. 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 EventSource on visibilitychange β†’ hidden and store lastEventId in sessionStorage; 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-ID as both a request header and a lastEventId query 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.

Deep Dives