Showing Connection Status in the UI Permalink to this section

Part of Error Handling & Reconnection UX.

Users interacting with real-time dashboards, live feeds, and chat interfaces have one reasonable expectation: they know whether what they are reading is current. When an SSE connection silently drops — the browser’s EventSource closes mid-session, or a mobile OS freezes the tab — stale data with no visual signal is worse than a loading state. This guide covers the exact mechanics of mapping EventSource.readyState to UI states, wiring reconnecting banners, and detecting a stream that is open but delivering no events (the silent-stale failure mode).

Symptom & Developer Intent Permalink to this section

The typical report sounds like: “The page looks fine but events stopped flowing 90 seconds ago — nobody noticed until a customer called.” The underlying scenarios are:

  • The EventSource silently enters CLOSED state after an onerror callback fires. The browser will schedule a reconnect (using the server-supplied retry interval or its own 3-second default), but during that window — and after repeated failures — the UI shows no signal.
  • A proxy or load balancer sends a TCP FIN after an idle timeout (common at 60 s for AWS ALB). The browser’s EventSource fires onerror, sets readyState = CLOSED, and begins the retry cycle. The page renders stale data as live.
  • The tab is backgrounded on mobile and the OS suspends JavaScript timers. The EventSource socket may have been closed server-side, but readyState can briefly show OPEN until the next tick.
  • A server-side heartbeat is absent, so no data has arrived for minutes but readyState still reads OPEN — the “zombie open” state that no built-in event signals.

The developer intent is threefold: reflect the true connection state in the UI at all times, prompt the user only when action is needed, and resume silently when the connection recovers.

Root Cause Analysis Permalink to this section

EventSource.readyState has three integer values defined by the WHATWG HTML spec:

Value Constant Meaning
0 EventSource.CONNECTING Initial connection or browser-driven reconnect in progress
1 EventSource.OPEN TCP connection established, stream active
2 EventSource.CLOSED Connection closed; browser will NOT automatically reconnect

The critical subtlety: readyState is a property snapshot, not a push event. When an onerror fires and the browser schedules a reconnect, readyState transitions OPEN → CLOSED → CONNECTING within milliseconds, but only the onerror and onopen callbacks mark those transitions. There is no onreconnecting event in the spec.

The second subtlety is the zombie-open problem. If the underlying TCP connection is kept alive by the OS or a proxy that swallows packets, readyState stays OPEN while the server has long since stopped sending. The spec has no built-in heartbeat. You must implement one at the application layer — typically a event: ping or a comment line (: keep-alive\n\n) that the server emits every 15–30 seconds — and track when the last event arrived on the client.

For background tab freezing, see Using the Page Visibility API to Pause Event Streams and Resuming SSE Streams After Mobile Tab Suspension, which cover OS-level suspension. Connection status UI must account for these by checking freshness on visibilitychange.

Step-by-Step Resolution Permalink to this section

Step 1 — Define your connection state model Permalink to this section

Collapse readyState plus application-layer staleness into a single enum before touching any UI code.

// connection-state.ts
export type ConnStatus = "connecting" | "live" | "reconnecting" | "stale" | "closed";

export function readyStateToStatus(rs: number): ConnStatus {
  if (rs === EventSource.CONNECTING) return "connecting";
  if (rs === EventSource.OPEN)       return "live";
  return "closed"; // CLOSED — distinct from "reconnecting" which we derive from event flow
}

The reconnecting and stale states are application-level extensions you synthesise from callback timing — the spec doesn’t surface them directly.

Step 2 — Wire the EventSource callbacks to your state machine Permalink to this section

// use-sse-status.ts  (framework-agnostic)
export function createSSEConnection(
  url: string,
  onStatus: (s: ConnStatus) => void,
  onEvent: (e: MessageEvent) => void,
  staleThresholdMs = 30_000,   // no event in 30 s → "stale"
  heartbeatEvent  = "ping",
) {
  let es: EventSource | null = null;
  let staleTimer: ReturnType<typeof setTimeout> | null = null;
  let reconnectAttempts = 0;

  function resetStaleTimer() {
    if (staleTimer) clearTimeout(staleTimer);
    staleTimer = setTimeout(() => onStatus("stale"), staleThresholdMs);
  }

  function connect() {
    es = new EventSource(url, { withCredentials: true });

    es.onopen = () => {
      reconnectAttempts = 0;
      onStatus("live");
      resetStaleTimer();
    };

    es.onerror = () => {
      if (staleTimer) clearTimeout(staleTimer);
      // readyState transitions to CLOSED immediately on error if the browser
      // will retry; it stays CLOSED if the connection is permanently gone.
      onStatus(es?.readyState === EventSource.CONNECTING ? "reconnecting" : "closed");
      reconnectAttempts++;
    };

    es.onmessage = (e: MessageEvent) => {
      onStatus("live");
      resetStaleTimer();
      onEvent(e);
    };

    // Track explicit heartbeat events to reset the stale timer
    es.addEventListener(heartbeatEvent, () => {
      onStatus("live");
      resetStaleTimer();
    });
  }

  function close() {
    if (staleTimer) clearTimeout(staleTimer);
    es?.close();
    onStatus("closed");
  }

  connect();
  return { close };
}

Key decisions: onerror checks readyState after the callback fires because the browser has already updated it by then. If it is CONNECTING (0), the browser is retrying; surface "reconnecting". If it is CLOSED (2), no automatic retry will happen; surface "closed".

Step 3 — Build the reconnecting banner component (React) Permalink to this section

// ConnectionBanner.tsx
import { useEffect, useRef, useState } from "react";
import { createSSEConnection, ConnStatus } from "./use-sse-status";

const LABELS: Record<ConnStatus, string> = {
  connecting:    "Connecting…",
  live:          "",              // no banner when healthy
  reconnecting:  "Connection lost — reconnecting…",
  stale:         "No new data in 30 s — stream may be stale",
  closed:        "Disconnected. Reload to reconnect.",
};

const STYLES: Record<ConnStatus, string> = {
  connecting:   "banner banner--info",
  live:         "",
  reconnecting: "banner banner--warning",
  stale:        "banner banner--warning",
  closed:       "banner banner--error",
};

export function ConnectionBanner({ url }: { url: string }) {
  const [status, setStatus] = useState<ConnStatus>("connecting");
  const [events, setEvents] = useState<string[]>([]);
  const connRef = useRef<{ close: () => void } | null>(null);

  useEffect(() => {
    const conn = createSSEConnection(url, setStatus, (e) => {
      setEvents((prev) => [...prev.slice(-99), e.data]); // keep last 100
    });
    connRef.current = conn;
    return () => conn.close();
  }, [url]);

  const label = LABELS[status];
  if (!label) return null;

  return (
    <div className={STYLES[status]} role="status" aria-live="polite">
      {label}
      {status === "closed" && (
        <button onClick={() => {
          connRef.current?.close();
          connRef.current = createSSEConnection(url, setStatus, () => {});
        }}>
          Reconnect
        </button>
      )}
    </div>
  );
}

The role="status" and aria-live="polite" attributes let screen readers announce state changes without interrupting reading flow. Use aria-live="assertive" only for the "closed" / critical error case.

Step 4 — Add a live indicator dot (Vue 3 Composition API) Permalink to this section

For dashboards that need a persistent status badge rather than a dismissible banner:

// composables/useSSEStatus.ts
import { ref, onUnmounted } from "vue";
import { createSSEConnection, ConnStatus } from "../use-sse-status";

export function useSSEStatus(url: string) {
  const status = ref<ConnStatus>("connecting");
  const lastEventAt = ref<Date | null>(null);

  const conn = createSSEConnection(
    url,
    (s) => { status.value = s; },
    () => { lastEventAt.value = new Date(); },
  );

  onUnmounted(() => conn.close());

  return { status, lastEventAt };
}
<!-- StatusDot.vue -->
<template>
  <span
    class="status-dot"
    :class="`status-dot--${status}`"
    :title="tooltipText"
    aria-label="Stream connection status"
  />
</template>

<script setup lang="ts">
import { computed } from "vue";
import type { ConnStatus } from "../use-sse-status";

const props = defineProps<{ status: ConnStatus }>();

const tooltipText = computed(() => ({
  connecting:   "Connecting to live stream",
  live:         "Live — receiving events",
  reconnecting: "Reconnecting…",
  stale:        "Stream open but no recent events",
  closed:       "Disconnected",
}[props.status]));
</script>

<style>
.status-dot { display:inline-block; width:10px; height:10px; border-radius:50%; }
.status-dot--live        { background:#1AAFA8; }
.status-dot--connecting  { background:#F4A821; animation: pulse 1.2s infinite; }
.status-dot--reconnecting{ background:#F4A821; animation: pulse 0.6s infinite; }
.status-dot--stale       { background:#E8644A; }
.status-dot--closed      { background:#5D6D7E; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
</style>

Step 5 — Handle Page Visibility for stale detection on tab resume Permalink to this section

When the browser wakes a frozen tab, the EventSource socket may have been closed server-side while readyState transiently shows OPEN. Force a freshness check on visibilitychange:

// Extend createSSEConnection to include visibility handling
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "visible") return;

  // If we've been hidden, assume the stream may be stale.
  // The stale timer will fire if no event arrives within the threshold.
  onStatus("stale");
  resetStaleTimer();

  // If readyState is CLOSED when the tab re-focuses, reconnect immediately
  // instead of waiting for the browser's exponential backoff.
  if (es?.readyState === EventSource.CLOSED) {
    es.close();
    connect();
  }
});

Pair this with the Event ID & Retry Mechanism Design guide: send Last-Event-ID on reconnect so the server can replay any missed events, preventing data gaps that would make a “live” indicator misleading.

Validation & Monitoring Permalink to this section

DevTools verification Permalink to this section

  1. Open Network → Filter: EventStream. Select the SSE request. The EventStream tab shows each frame; a gap in frames confirms a stale period.
  2. Throttle to Offline in DevTools → Network Conditions. The banner should transition from live → reconnecting within 1–2 seconds and the dot should pulse amber.
  3. Restore to Online. The banner should disappear and the dot should return to teal within the retry interval (default 3 s, or server-configured).

Unit test stub (Vitest / jsdom) Permalink to this section

// connection-status.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";

// Minimal EventSource mock
class MockES extends EventTarget {
  static CONNECTING = 0; static OPEN = 1; static CLOSED = 2;
  readyState = MockES.CONNECTING;
  constructor(public url: string) { super(); }
  open()  { this.readyState = MockES.OPEN;    this.dispatchEvent(new Event("open")); }
  fail()  { this.readyState = MockES.CLOSED;  this.dispatchEvent(new Event("error")); }
  close() { this.readyState = MockES.CLOSED; }
  onopen: ((e: Event) => void) | null = null;
  onerror: ((e: Event) => void) | null = null;
  onmessage: ((e: MessageEvent) => void) | null = null;
}

beforeEach(() => { (global as any).EventSource = MockES; });

describe("SSE status transitions", () => {
  it("transitions to live on open", () => {
    const states: string[] = [];
    // ... wire createSSEConnection with MockES and assert states sequence
    expect(states).toContain("live");
  });

  it("transitions to reconnecting on error while CONNECTING", () => {
    // Verify onerror with readyState=CONNECTING produces "reconnecting"
    expect(true).toBe(true); // replace with real assertion
  });
});

curl health check Permalink to this section

# Stream for 5 s; confirm heartbeat comment lines arrive within 20 s
curl -s -N -H "Accept: text/event-stream" https://api.example.com/events \
  | timeout 25 grep -m 1 ': keep-alive' \
  && echo "Heartbeat OK" || echo "FAIL: no heartbeat received"

If no heartbeat arrives within your stale threshold, the server-side heartbeat emitter needs attention — see Node.js Streaming Architecture Basics for how to add a periodic flush loop.

Verification Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why does readyState show OPEN right after onerror fires?

It doesn't — but it can appear that way if you read it synchronously inside the error handler before the browser updates it. The spec guarantees readyState is updated before the event fires in modern engines, but the safest pattern is to read it on the next microtask: Promise.resolve().then(() => es.readyState). In practice, reading it synchronously in onerror reliably returns CLOSED or CONNECTING in all major browsers as of 2025.

How do I prevent the "reconnecting" banner from flickering on brief network hiccups?

Debounce the status transition to "reconnecting" by 500–800 ms. If onopen fires within that window (the reconnect succeeded immediately), cancel the banner transition entirely. Use setTimeout in your onerror handler and clearTimeout in onopen. Do not debounce the "live" transition — show it immediately so recovery feels instant.

My EventSource has withCredentials: true — does that affect readyState behaviour?

No. The withCredentials option controls whether cookies and authorization headers are sent; it does not change the readyState lifecycle or the error/open event sequence. CORS preflight for credentialed SSE endpoints can delay the initial onopen by one round-trip, which briefly extends the "connecting" state — this is expected. See Handling CORS in SSE Implementations for the required server-side headers.

Should I use a Redux slice or local component state for connection status?

Local component state is correct for status indicators (banners, dots). Push connection status into global state only if multiple disconnected UI regions need to react — for example, a sidebar indicator AND an inline error boundary both responding to "closed". See State-Management Integration for SSE and specifically Syncing SSE Streams with Redux State for the Redux slice pattern.

How do I test stale detection without waiting 30 seconds?

Inject the stale threshold as a parameter (as shown in createSSEConnection above) and pass staleThresholdMs = 200 in tests. Advance fake timers with vi.useFakeTimers() + vi.advanceTimersByTime(201) to trigger the stale callback synchronously in your test suite. Never hard-code the 30 s constant inside the function body.

⚡ Production Directives

  • Emit a server-side heartbeat (comment line or named event) every 15–25 s so clients can detect zombie-open connections without relying on TCP timeouts.
  • Set the stale detection threshold to 1.5× the heartbeat interval — never below the heartbeat interval, or you'll generate false stale alerts.
  • Debounce the "reconnecting" banner by 500 ms to absorb instantaneous browser-driven reconnects that complete before the user can read the message.
  • Always send Last-Event-ID on reconnect and replay missed events server-side, so a "live" indicator after reconnect is truthful rather than misleading.
  • Test your status UI against the DevTools Network → Offline throttle preset as part of every CI smoke run — automate with Playwright's page.route or context.setOffline(true).