# How Pi Web Handles Missed Server-Sent Events (SSE) for Running Sessions

> Discover how Pi Web ensures UI synchronization with its backend by handling missed Server-Sent Events (SSE) through a robust three-layer recovery strategy including polling, event triggers, and heartbeats.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-07-30

---

**Pi Web handles missed Server-Sent Events for running sessions through a coordinated three-layer recovery strategy: periodic 5-second reconciliation polling, browser visibility/online event triggers, and server-side heartbeat streams that ensure the UI remains synchronized with the backend even when SSE connections drop.**

The `agegr/pi-web` repository implements a resilient failover mechanism to maintain real-time agent session synchronization during network instability. By combining proactive client-side polling with browser lifecycle events and server-side session registries, Pi Web gracefully recovers from interrupted SSE streams without leaving the UI in an inconsistent state.

## Three-Layer Recovery Strategy for Missed SSE

When a running session loses its SSE connection due to network drops, backgrounded tabs, or half-open connections, the system executes three coordinated recovery mechanisms defined in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) and [`app/api/agent/running/events/route.ts`](https://github.com/agegr/pi-web/blob/main/app/api/agent/running/events/route.ts).

### Periodic Reconciliation Polling

While a session is marked as *running*, a `setInterval` loop configured to `AGENT_STATE_RECONCILE_MS` (approximately 5 seconds) repeatedly invokes `reconcileAgentState()`. This function fetches the canonical server state via `GET /api/agent/[id]` to detect if the session has completed without the client receiving the `agent_end` event. If the server reports an idle state while the client still shows a running UI, the system calls `finishPromptWithoutStream` to safely finalize the prompt display. This logic is implemented in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) (lines 822‑852 and 858‑775).

### Browser Visibility and Online Events

The reconciliation logic triggers immediately when the browser tab becomes visible again (`visibilitychange` event) or the network reconnects (`online` event). This catches scenarios where the browser froze the tab and buffered SSE messages only flush after the user returns to the foreground. The event listeners are registered in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) (lines 866‑873 for visibility and 874‑875 for online handling).

### Server-Side Heartbeat and Session Registry

The server exposes an SSE endpoint at `/api/agent/running/events` that streams the set of currently running session IDs while emitting a heartbeat (`:\n\n`) every 30 seconds to keep proxies and load balancers from terminating idle connections. If the client loses this stream entirely, the periodic polling from the client side still guarantees eventual consistency. The heartbeat implementation resides in [`app/api/agent/running/events/route.ts`](https://github.com/agegr/pi-web/blob/main/app/api/agent/running/events/route.ts) (lines 30‑38), while the global session registry is maintained in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) via `getRunningRpcSessionIds` and `subscribeRunningSessions`.

## Reconciliation Logic Deep Dive

The `reconcileAgentState` function implements stale-response guards and safe completion handling to prevent out-of-order updates:

```typescript
// hooks/useAgentSession.ts (excerpt)
const reconcileAgentState = useCallback(async (sid: string) => {
  if (!agentRunningRef.current) return;
  const runId = promptRunIdRef.current;
  try {
    const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`);
    if (!res.ok) return;
    const data = await res.json() as { running?: boolean; state?: AgentStateResponse };
    if (promptRunIdRef.current !== runId) return;               // stale response
    const state = data.state;
    setIsCompacting(state?.isCompacting ?? false);
    setQueuedMessages(normalizeQueuedMessages(state?.queuedMessages));
    const busy = data.running && state &&
      (state.isStreaming || state.isPromptRunning || state.isCompacting);
    if (busy || !agentRunningRef.current) return;               // still busy
    await finishPromptWithoutStream(sid, runId);                // safe completion
  } catch {
    // Network still down – next tick will retry.
  }
}, [finishPromptWithoutStream]);

```

A `useEffect` hook wires this reconciliation to both the periodic interval and browser lifecycle events:

```typescript
// hooks/useAgentSession.ts (excerpt)
useEffect(() => {
  if (!agentRunning) return;
  const reconcile = () => {
    const sid = sessionIdRef.current;
    if (sid) void reconcileAgentState(sid);
  };
  const onVisible = () => {
    if (document.visibilityState === "visible") reconcile();
  };
  const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS);
  document.addEventListener("visibilitychange", onVisible);
  window.addEventListener("online", reconcile);
  return () => {
    clearInterval(interval);
    document.removeEventListener("visibilitychange", onVisible);
    window.removeEventListener("online", reconcile);
  };
}, [agentRunning, reconcileAgentState]);

```

## Preventing Duplicate UI Updates

To avoid race conditions where a delayed `agent_end` SSE event arrives after reconciliation has already finished the prompt locally, the event handlers check `agentRunningRef.current` before processing. If the session is already marked as complete, the handler ignores the late event (`if (!agentRunningRef.current) return`), preventing duplicate completion indicators and state corruption in the UI.

## Summary

- **Periodic polling**: Every 5 seconds, `reconcileAgentState()` in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) fetches the ground truth from `GET /api/agent/[id]` to detect missed completion events.
- **Lifecycle triggers**: `visibilitychange` and `online` events force immediate reconciliation when the browser resumes from background or network disconnection.
- **Server heartbeat**: The `/api/agent/running/events` endpoint emits a 30-second SSE heartbeat and maintains a registry of active sessions via [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts).
- **Idempotent completion**: The `finishPromptWithoutStream` function safely finalizes UI state even when the primary SSE stream is irrecoverably lost, with guards against duplicate updates.

## Frequently Asked Questions

### What happens if the SSE connection drops while a prompt is streaming?

The `setInterval` polling continues every 5 seconds (configured via `AGENT_STATE_RECONCILE_MS`). When the poll detects the server-side session has completed via `GET /api/agent/[id]`, it invokes `finishPromptWithoutStream` to close the streaming bubble and update the UI state accordingly, ensuring the user sees the final result even if the original SSE stream never recovered.

### How does Pi Web prevent duplicate completion messages when the SSE reconnects?

Before processing any `agent_end` event, the handler validates `agentRunningRef.current`. If the reconciliation logic has already marked the session as finished (setting `agentRunningRef.current` to false), the late-arriving SSE event is discarded. This ensures exactly-once UI updates even when the server emits completion events after the client has already cleaned up the session state.

### Why does the server send a heartbeat every 30 seconds?

The SSE heartbeat (`:\n\n`) sent by [`app/api/agent/running/events/route.ts`](https://github.com/agegr/pi-web/blob/main/app/api/agent/running/events/route.ts) every 30 seconds prevents intermediate proxies, load balancers, and browser network stacks from terminating idle connections. This keeps the session registry stream alive for the duration of long-running agent tasks while providing a lightweight keepalive mechanism that clients can ignore.

### Where does the server track which sessions are currently running?

The global session registry is maintained in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts), which exports `getRunningRpcSessionIds` and `subscribeRunningSessions`. The SSE endpoint at `/api/agent/running/events` queries this registry to broadcast active session IDs to all connected clients, providing the authoritative source of truth for the reconciliation logic.