Resuming SSE Streams After Mobile Tab Suspension Permalink to this section

Part of Mobile & Background-Tab Handling.

Mobile operating systems aggressively suspend background tabs to reclaim CPU and radio resources. When the user returns to your tab, an EventSource connection that was open before suspension is silently dead — the TCP socket was torn down by the OS network stack, but the JavaScript object still appears connected (readyState === 1). The browser will not fire an error event or trigger the built-in reconnect unless it detects the disconnect, which on suspended tabs can take 30–90 seconds or never happen at all. The result: the client misses all events emitted during suspension and displays stale state with no indication anything went wrong.

This guide walks through detecting the resume signal, forcing a clean reconnection that sends Last-Event-ID, and reconciling the gap between the last received event and the current server state.

Symptom & Developer Intent Permalink to this section

Observable symptoms:

  • After returning from a background tab on iOS Safari or Android Chrome, the UI shows stale data. No reconnection spinner appears, and the browser’s Network panel shows the SSE request as still “pending” but no new data arrives.
  • EventSource.readyState returns 1 (OPEN) even though the server closed the TCP connection minutes ago.
  • Occasionally onerror fires with a brief close/reconnect cycle — but only after the OS-level TCP keepalive timeout (~30 s by default on Android, longer on iOS), by which point the user has already seen a stale screen.

Developer intent: force a reliable reconnect the instant the tab becomes visible again, carry Last-Event-ID so the server can replay missed events, and fill any gaps from server-side history if the stream has been idle for longer than the server’s event buffer window.

Root Cause Analysis Permalink to this section

Why mobile suspends connections Permalink to this section

iOS and Android implement aggressive background app lifecycle policies. When a browser tab loses focus and the OS decides to throttle or freeze it:

  1. Network sockets are paused or closed. The radio may be powered down. An existing TCP connection receives a RST or simply silently drops.
  2. JavaScript timers and microtasks stop. No JavaScript runs; the EventSource internal reconnect timer never fires.
  3. The browser does not immediately detect the dead socket. Without application-level heartbeats, the TCP connection appears open to the kernel until a keepalive probe or a write attempt fails. On most mobile OSes this takes 30–75 s.

Why EventSource misses this Permalink to this section

The WHATWG SSE specification defines reconnection only for explicit server closes (Connection: close) or network errors the browser catches. A silently frozen socket does not trigger the reconnect algorithm until the keepalive timeout elapses — well after the tab is visible again.

Why Last-Event-ID alone is not enough Permalink to this section

Even when the browser eventually reconnects and sends Last-Event-ID, the server’s in-memory event buffer has a finite retention window (typically 60–300 s). If the tab was suspended longer than that window, the server cannot replay the missed events and must indicate a gap. Without explicit gap reconciliation on the client, the UI may display a partial or inconsistent state. See also Event ID & Retry Mechanism Design for how the spec defines event replay semantics.

Step-by-Step Resolution Permalink to this section

Step 1 — Track the last received event ID Permalink to this section

Before you can resume correctly, you need to record the ID of every event received so you can supply it during reconnection independently of the browser’s internal lastEventId tracking (which resets if you create a new EventSource object).

// sse-client.ts
let lastEventId: string | null = null;

function attachListeners(es: EventSource): void {
  es.addEventListener("message", (e: MessageEvent) => {
    if (e.lastEventId) {
      lastEventId = e.lastEventId;        // persist across reconnects
    }
    handleEvent(e);
  });

  // named event types follow the same pattern
  es.addEventListener("update", (e: MessageEvent) => {
    if (e.lastEventId) lastEventId = e.lastEventId;
    handleEvent(e);
  });
}

For longer sessions, also persist lastEventId to sessionStorage so a full page reload can resume from the correct position:

es.addEventListener("message", (e: MessageEvent) => {
  if (e.lastEventId) {
    lastEventId = e.lastEventId;
    sessionStorage.setItem("sse_last_id", e.lastEventId);
  }
});

// On startup, seed from storage
lastEventId = sessionStorage.getItem("sse_last_id");

Step 2 — Detect tab resume with visibilitychange Permalink to this section

The visibilitychange event fires synchronously when the tab becomes visible. This is the earliest reliable hook — well before the TCP keepalive detects the dead socket. See Using the Page Visibility API to Pause Event Streams for more on this API.

// visibility-handler.ts
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") {
    // Tab just became foreground — assume connection is stale
    scheduleReconnect();
  }
});

Step 3 — Force a clean reconnection with Last-Event-ID Permalink to this section

EventSource sends Last-Event-ID only during its built-in reconnect. When you create a new EventSource manually, the browser does not send it automatically. You must append the ID as a query parameter or request header — and since EventSource does not accept custom headers, a query parameter is the standard approach. Confirm the server reads Last-Event-ID header on native reconnects and the lastEventId query param on forced reconnects.

// reconnect.ts
let eventSource: EventSource | null = null;

function buildUrl(base: string, lastId: string | null): string {
  if (!lastId) return base;
  const url = new URL(base, location.href);
  url.searchParams.set("lastEventId", lastId);
  return url.toString();
}

function connect(base: string): void {
  if (eventSource) {
    eventSource.close();      // tear down before creating a new one
    eventSource = null;
  }

  const url = buildUrl(base, lastEventId);
  eventSource = new EventSource(url, { withCredentials: true });
  attachListeners(eventSource);

  eventSource.onerror = (e) => {
    console.warn("SSE error", e);
    // built-in reconnect will fire; no manual action needed here
  };
}

function scheduleReconnect(): void {
  // Debounce: avoid reconnect storms if visibilitychange fires repeatedly
  clearTimeout(reconnectTimer);
  reconnectTimer = window.setTimeout(() => connect(SSE_ENDPOINT), 80);
}

let reconnectTimer = 0;

Step 4 — Implement server-side gap detection and response Permalink to this section

The server must compare the client’s lastEventId against the current head of the event log. If the gap is within the buffer window, replay missed events. If it exceeds the window, emit a synthetic gap event so the client knows to re-fetch full state.

# FastAPI example — gap detection on reconnect
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, time

app = FastAPI()
EVENT_BUFFER: list[dict] = []   # replace with Redis XRANGE or DB query
BUFFER_WINDOW_S = 120           # how long events are retained

@app.get("/events")
async def sse_endpoint(request: Request, lastEventId: str | None = None):
    async def generator():
        gap_detected = False

        if lastEventId:
            # find events after lastEventId
            after = [e for e in EVENT_BUFFER if int(e["id"]) > int(lastEventId)]
            if not after and EVENT_BUFFER:
                oldest = EVENT_BUFFER[0]
                if time.time() - oldest["ts"] > BUFFER_WINDOW_S:
                    gap_detected = True

            if gap_detected:
                yield "event: gap\ndata: {}\n\n"      # client must re-fetch state
            else:
                for event in after:
                    yield f"id: {event['id']}\ndata: {event['data']}\n\n"

        # live tail
        while not await request.is_disconnected():
            await asyncio.sleep(1)
            # emit new events here
            yield ": heartbeat\n\n"    # keep-alive comment

    return StreamingResponse(generator(), media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

For a Redis-backed fan-out pattern that scales this across nodes, see Redis Pub/Sub Fan-Out for SSE.

Step 5 — Handle the gap event on the client Permalink to this section

When the server signals a gap, the client must discard local state and re-fetch the full resource via a normal HTTP request, then re-subscribe to live events.

// gap-handler.ts
function attachListeners(es: EventSource): void {
  es.addEventListener("gap", async () => {
    console.warn("SSE gap detected — re-fetching full state");
    es.close();               // stop listening to stale stream

    const state = await fetch("/api/state").then(r => r.json());
    applyFullState(state);    // replace local state entirely

    // Re-connect fresh — no lastEventId; let server send from HEAD
    lastEventId = null;
    sessionStorage.removeItem("sse_last_id");
    connect(SSE_ENDPOINT);
  });
}

Gap reconciliation decision table Permalink to this section

Client lastEventId Server buffer state Server action Client action
null / absent Any Start from HEAD Apply incremental events
Present, within buffer Events exist after ID Replay missed events Apply incremental events
Present, within buffer No newer events Send heartbeat, then live tail No action needed
Present, outside buffer Buffer has older events Emit gap event Full state re-fetch
Present, ID unknown ID not in log Emit gap event Full state re-fetch

Validation & Monitoring Permalink to this section

Manual DevTools walkthrough Permalink to this section

  1. Open the page in Chrome DevTools → Network tab → filter by EventStream.
  2. Confirm the SSE request is active and receiving events.
  3. Switch to another tab for 60+ seconds (simulate suspension).
  4. Return to the tab — within 200 ms of visibilitychange, a new EventStream request should appear in the Network tab.
  5. Inspect the new request’s headers: Last-Event-ID header should be present (for native reconnect) or ?lastEventId=<id> in the URL (for forced reconnect).
  6. In the EventStream sub-panel, verify replay events arrive immediately, or a gap event triggers a state refresh.

Automated integration test (Playwright) Permalink to this section

// resume.spec.ts
import { test, expect } from "@playwright/test";

test("reconnects with lastEventId after tab suspension", async ({ page }) => {
  await page.goto("/dashboard");
  // wait for initial SSE connection
  await page.waitForResponse(r => r.url().includes("/events"));

  // simulate tab hide/show
  await page.evaluate(() => {
    Object.defineProperty(document, "visibilityState", { value: "hidden", writable: true });
    document.dispatchEvent(new Event("visibilitychange"));
  });
  await page.waitForTimeout(200);
  await page.evaluate(() => {
    Object.defineProperty(document, "visibilityState", { value: "visible", writable: true });
    document.dispatchEvent(new Event("visibilitychange"));
  });

  // new SSE request should include lastEventId
  const req = await page.waitForRequest(r =>
    r.url().includes("/events") && r.url().includes("lastEventId=")
  );
  expect(req.url()).toMatch(/lastEventId=\d+/);
});

curl verification of server replay Permalink to this section

# Verify the server replays events after a given ID
curl -N -H "Accept: text/event-stream" \
  "https://api.example.com/events?lastEventId=4200" \
  --max-time 5

# Expected: events with id > 4200 arrive immediately
# If no output within 2s, the server is not replaying — check buffer logic

Heartbeat round-trip metric Permalink to this section

Emit a named ping event every 15–20 s. On the client, track Date.now() at last receipt. If the delta exceeds 45 s when the tab is visible, trigger reconnect proactively:

let lastPingAt = Date.now();

es.addEventListener("ping", () => { lastPingAt = Date.now(); });

setInterval(() => {
  if (document.visibilityState === "visible" && Date.now() - lastPingAt > 45_000) {
    console.warn("Heartbeat timeout — forcing reconnect");
    scheduleReconnect();
  }
}, 10_000);

⚡ Production Directives

  • Always close the existing EventSource before creating a new one; orphaned connections consume server file descriptors.
  • Persist lastEventId to sessionStorage so full page reloads can resume without a full-state fetch.
  • Configure server heartbeats (comment lines) at ≤20 s intervals — this is your primary mechanism for detecting dead connections before the user notices.
  • Retain server-side event history for at least 120 s; tune to your median mobile suspension duration from analytics.
  • Emit a named gap event instead of silently dropping the client into live-tail — silent gaps cause subtle data inconsistency bugs.

Verification Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Does iOS Safari support visibilitychange reliably?

Yes, since iOS 13. The event fires when the tab is backgrounded or the screen is locked. On older iOS (pre-13), pagehide and pageshow are the fallback pair. Add both listeners in production to cover stragglers. Note that iOS freezes JavaScript execution after ~30 s in background, so even the visibilitychange handler may fire late on very old devices — the heartbeat timeout monitor provides a safety net.

Can I use the browser's built-in EventSource reconnect instead of forcing one manually?

The built-in reconnect works if the server closes the connection cleanly or if the network error is detected. On mobile suspension, the socket is silently killed by the OS and the browser may not detect this for 30–75 s. For a responsive UX, always force a reconnect on visibilitychange — close the old EventSource and create a new one within 200 ms of the tab becoming visible. The built-in reconnect is a useful fallback, not the primary mechanism.

What if my server does not support Last-Event-ID replay?

If the server has no event history, the client should perform a full state fetch via a normal REST or GraphQL call immediately after reconnecting. Design your state model so that a full snapshot can replace incremental events — this is simpler to reason about and makes gap handling a no-op. Avoid using SSE as the sole mechanism for critical state if your server cannot replay.

How do I prevent reconnect storms when many tabs resume simultaneously?

Add a small random jitter to the reconnect delay: setTimeout(connect, 80 + Math.random() * 400). At the server side, also implement connection rate limiting per IP or user. See Rate Limiting & Backpressure Handling for token-bucket patterns that absorb bursts.

Should I use fetch + ReadableStream instead of EventSource on mobile?

fetch with a ReadableStream body gives you full header control (you can send Last-Event-ID as a proper header) and works inside Service Workers. The tradeoff: you must implement the SSE framing parser yourself and manage reconnect logic manually. For most cases, the query-parameter workaround for EventSource is simpler. If you need custom auth headers or Service Worker streaming, the Error Handling & Reconnection UX guide covers the fetch-based approach.