# How Pi-Web Handles Server-Sent Events (SSE) Reconnection on Page Refresh

> Discover how Pi-Web manages Server-Sent Events (SSE) reconnection after a page refresh. Explore its strategy for handling transient and fatal errors to ensure a robust connection.

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

---

**Pi-Web instantiates a fresh `EventSource` on every page load, waits for a server-side "connected" handshake, and implements a manual retry loop for fatal errors while relying on the browser's native auto-reconnect for transient failures.**

When a user refreshes the browser in the `agegr/pi-web` repository, the React chat interface must re-establish its Server-Sent Events (SSE) channel to continue receiving real-time agent updates. The `useAgentSession` hook manages this SSE reconnection lifecycle by creating a new stream, validating the server handshake, and recursively retrying after terminal connection failures.

## Creating a Fresh EventSource on Mount

After a page refresh, the component remounts and immediately invokes `connectEvents` to build a new SSE connection. Unlike WebSocket reuse strategies, Pi-Web explicitly closes any stale references and constructs a pristine `EventSource` pointing to the session-specific endpoint.

```typescript
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);

```

See [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) lines 599–600.

The hook stores this instance in `eventSourceRef` to track the current stream and prevent duplicate connections during React's strict mode or rapid state transitions.

## The Server-Side Connection Handshake

Before the client considers the stream active, it awaits a JSON message with `type: "connected"` sent by the server-side handler in `app/api/agent/[id]/events/route.ts`. This handshake confirms that the `AgentSession` managed by [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) is ready to push updates.

```typescript
es.onmessage = (e) => {
  const event = JSON.parse(e.data);
  if (event.type === "connected") settle("connected");
  handleAgentEventRef.current?.(event);
};

```

See [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) lines 614–616.

The `settle` function resolves the internal promise with status `"connected"`, unblocking any UI actions that depend on a live event stream.

## Handling Fatal vs. Transient Errors

Pi-Web distinguishes between recoverable network blips and terminal failures. When `es.readyState === EventSource.CLOSED`—typically caused by HTTP 404/500 errors or content-type mismatches—the hook triggers a manual reconnection loop.

```typescript
if (es.readyState === EventSource.CLOSED) {
  settle("closed");
  if (eventSourceRef.current === es && agentRunningRef.current) {
    eventSourceRef.current = null;
    setTimeout(() => {
      if (agentRunningRef.current) void connectEvents(sid);
    }, 1_000);
  }
}

```

See [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) lines 622–630.

For transient errors while the state remains `CONNECTING`, the code intentionally does nothing. The browser's native `EventSource` auto-reconnect logic handles these brief interruptions automatically.

## Guaranteed Connection with ensureEventsConnected

Higher-level chat operations depend on `ensureEventsConnected`, which wraps `connectEvents` and enforces a strict timeout. If the server fails to send the handshake within **5 seconds** (configurable via `EVENT_STREAM_CONNECT_TIMEOUT_MS`), the promise rejects with an `EventStreamConnectionError`.

```typescript
const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS);

```

See [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) lines 334–336.

This guard ensures that message sending, session forking, or tool execution only occur after the SSE channel is fully validated, preventing race conditions on slow networks.

## Complete Implementation Reference

Below is the consolidated reconnection logic from [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) showing the full lifecycle from instantiation to error recovery:

```typescript
const connectEvents = useCallback((sid: string): Promise<EventStreamConnectionResult> => {
  // Clean up any existing stream before creating a new one
  if (eventSourceRef.current) {
    eventSourceRef.current.close();
    eventSourceRef.current = null;
  }

  const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
  eventSourceRef.current = es;

  return new Promise((resolve) => {
    const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS);
    let settled = false;

    const settle = (status: EventStreamConnectionStatus) => {
      if (settled) return;
      settled = true;
      clearTimeout(timeout);
      resolve({ status, source: es });
    };

    es.onmessage = (e) => {
      const ev = JSON.parse(e.data);
      if (ev.type === "connected") settle("connected");
      handleAgentEventRef.current?.(ev);
    };

    es.onerror = () => {
      if (es.readyState === EventSource.CLOSED) {
        // Fatal error: initiate manual reconnect
        settle("closed");
        if (eventSourceRef.current === es && agentRunningRef.current) {
          eventSourceRef.current = null;
          setTimeout(() => {
            if (agentRunningRef.current) void connectEvents(sid);
          }, 1_000);
        }
      }
      // Transient errors: allow browser auto-reconnect
    };
  });
}, []);

// Triggered on mount or when sessionId changes
useEffect(() => {
  if (!sessionId) return;
  ensureEventsConnected(sessionId).catch(console.error);
}, [sessionId, ensureEventsConnected]);

```

See [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) lines 641–647 for the `ensureEventsConnected` implementation.

## Key Files and Their Roles

| File | Role |
|------|------|
| [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) | Client-side hook that creates the `EventSource`, handles auto-reconnection, and exposes the SSE stream to the UI. |
| `app/api/agent/[id]/events/route.ts` | Server-side endpoint that streams JSON events (`connected`, `assistant`, `toolResult`, etc.) to the client. |
| [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) | Manages the lifecycle of `AgentSession` objects; used by the events route to push updates into the stream. |
| [`lib/normalize.ts`](https://github.com/agegr/pi-web/blob/main/lib/normalize.ts) | Normalizes incoming tool-call payloads; referenced by the SSE handler to keep message shapes consistent. |

## Summary

- **Fresh instantiation**: Every page refresh creates a new `EventSource` via `connectEvents`, discarding any stale browser-side state.
- **Handshake validation**: The client waits for a `{type:"connected"}` message before considering the stream ready, preventing premature UI actions.
- **Dual error handling**: Fatal errors (`CLOSED` state) trigger a 1-second delayed manual retry using `setTimeout`, while transient errors rely on native browser auto-reconnect.
- **Timeout protection**: A 5-second configurable timeout prevents indefinite hanging when the server is unreachable.
- **Session-aware cleanup**: Reconnection only occurs if `agentRunningRef.current` remains true, ensuring retries stop when the agent session ends.

## Frequently Asked Questions

### Does Pi-Web reuse the same EventSource after a page refresh?

No. According to the `agegr/pi-web` source code, every page refresh triggers a new component mount, which explicitly closes any existing `EventSource` and creates a fresh instance. The previous reference is cleared from `eventSourceRef` to prevent memory leaks and connection duplication.

### What triggers the manual reconnection logic?

The manual retry loop activates only when `es.readyState === EventSource.CLOSED`, indicating a fatal error such as an HTTP 404, 500, or content-type mismatch. The code checks `eventSourceRef.current === es` to ensure it only retries the most recent connection attempt, then schedules `connectEvents(sid)` to run after 1,000 milliseconds if the agent is still running.

### How long does Pi-Web wait for the SSE connection to establish?

The promise returned by `connectEvents` settles with status `"timeout"` if the server fails to send the handshake within **5 seconds** (controlled by `EVENT_STREAM_CONNECT_TIMEOUT_MS`). This allows callers to fall back to polling or display connection error states rather than waiting indefinitely.

### How does the server indicate a successful SSE connection?

The server-side endpoint at `app/api/agent/[id]/events/route.ts` emits a JSON payload with `{"type":"connected"}` immediately upon accepting the stream. The client resolves its connection promise only after parsing this message, guaranteeing that the `AgentSession` in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) is actively streaming before the UI enables chat interactions.