# How ego-browser Manages Session Persistence and Reattachment in CDP

> Discover how ego-browser ensures session persistence and reattachment via its cached-with-TTL model. Learn about its automatic reattachment and transparent retry logic for CDP targets.

- Repository: [CitroLabs/ego-lite](https://github.com/citrolabs/ego-lite)
- Tags: internals
- Published: 2026-07-30

---

**ego-browser implements a "cached-with-TTL" session model that automatically reattaches to Chrome DevTools Protocol (CDP) targets when connections drop, using a 2-second freshness window and transparent retry logic.**

The ego-browser runtime, hosted within the **citrolabs/ego-lite** framework, maintains persistent Chrome DevTools Protocol sessions across intermittent agent script executions. Unlike standard CDP clients that assume continuous connectivity, ego-browser implements sophisticated **ego-browser session persistence and reattachment** logic to handle idle periods, target crashes, and navigation events without manual intervention.

## Session Lifecycle and Freshness Windows

The runtime defines strict temporal boundaries for session validity in [`package/ego-browser/src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts). These constants govern how long a session ID remains usable before forced revalidation:

```typescript
const RESPONSE_TIMEOUT_MS = 15000;   // CDP request timeout
const SESSION_TTL_MS      = 2000;    // "freshness" window for a cached session
const MAX_BUFFERED_EVENTS = 10000;   // Upper limit for the internal event buffer

```

The **`SESSION_TTL_MS`** (2 seconds) represents the maximum age of a cached `state.sessionId`. When an agent script executes after this window, the runtime automatically discards the stale identifier and negotiates a new attachment. This prevents the use of expired sessions that Chrome may have garbage collected during idle periods.

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L4‑L6)

## Lazy Session Initialization via ensureSession()

All high-level CDP operations route through `ensureSession()` unless the caller provides an explicit `sessionId` or invokes a browser-level command (`Target.*` or `Browser.*`). This function implements a three-tier caching strategy to minimize redundant attachments.

### Cache Checks and Promise Sharing

The implementation first validates cached state, then deduplicates concurrent attachment attempts:

```typescript
export async function ensureSession() {
  // 1️⃣ Return cached session if still recent
  if (state.sessionId && Date.now() - state.sessionAt < SESSION_TTL_MS) {
    return state.sessionId;
  }

  // 2️⃣ If another call is already inflight, share its promise
  if (state.sessionInflight) {
    return state.sessionInflight;
  }

  // 3️⃣ Otherwise create a new session (see below)
  state.sessionInflight = (async () => {
    // …attach logic …
  })();

  return state.sessionInflight;
}

```

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L7‑L15)

### Attaching to Chrome Targets

When cache misses occur, the runtime queries the host process via `ego.listTabs()` to identify available targets. It prioritizes user-specified tabs over the active tab, then establishes a new CDP session via `Target.attachToTarget`:

```typescript
const result = assertNoEgoError(await browserEgo().listTabs());
const tabs = result?.tabs || result?.targetInfos || [];
const preferred = state.preferredTargetId
  ? tabs.find((t) => t.targetId === state.preferredTargetId)
  : null;
const active = preferred || tabs.find((t) => t.active) || tabs[tabs.length - 1];
const targetId = active.targetId;

if (targetId !== state.sessionTargetId || !state.sessionId) {
  const attached = await rawCdp(
    "Target.attachToTarget",
    { targetId, flatten: true },
    undefined,
  );
  state.sessionId = attached.result?.sessionId || attached.sessionId;
  state.sessionTargetId = targetId;
}

```

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L16‑L35)

### Enabling Page Events

After successful attachment, the runtime enables the `Page` domain to receive events like dialogs and screencasts. This occurs exactly once per session to avoid protocol errors:

```typescript
await enablePageEvents(state.sessionId);
state.sessionAt = Date.now();   // Reset the "freshness" timestamp
return state.sessionId;

```

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L36‑L44)

## Automatic Reattachment on Session Loss

When CDP requests fail, `browserCdp()` inspects error messages against the `SESSION_LOST` regular expression. If the error indicates a detached session and the call wasn't explicitly targeting a specific session, the runtime executes transparent recovery.

### The Recovery Loop

The error handler invalidates stale state and retries exactly once:

```typescript
} catch (error) {
  const lost = SESSION_LOST.test(error?.message || "");
  if (lost && !explicit && !BROWSER_LEVEL(method)) {
    invalidateSession();
    const fresh = await ensureSession();
    return rawCdp(method, params, fresh, timeoutMs);
  }
  throw error;
}

```

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L95‑L103)

### Invalidating Session State

The `invalidateSession()` function clears all cached identifiers and cleans up associated memory:

```typescript
export function invalidateSession() {
  if (state.sessionId) {
    pageEnabledSessions.delete(state.sessionId);
    pendingDialogs.delete(state.sessionId);
  }
  state.sessionId = null;
  state.sessionTargetId = null;
  state.sessionAt = 0;
}

```

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L46‑L54)

### Reactive Cleanup via CDP Events

The runtime listens for `Target.detachedFromTarget` and `Target.targetDestroyed` events. When these target the currently attached session, `invalidateSession()` triggers immediately, preventing the reuse of dead connections:

```typescript
if (
  data.method === "Target.detachedFromTarget" ||
  data.method === "Target.targetDestroyed"
) {
  // …snip…
  if (targetId && targetId === state.sessionTargetId) {
    invalidateSession();
  }
}

```

(📂 **[browser-runtime.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/browser-runtime.ts)** L52‑L64)

## State Management Architecture

All session metadata resides in a singleton state object exported from [`package/ego-browser/src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/state.ts). This centralized store contains `sessionId`, `sessionTargetId`, `sessionAt`, and `sessionInflight` flags that coordinate attachment across asynchronous operations.

```typescript
// Conceptual structure from state.ts
export const state = {
  sessionId: null,       // Current CDP session identifier
  sessionTargetId: null, // Chrome target ID
  sessionAt: 0,          // Timestamp of last attachment
  sessionInflight: null  // Promise for deduplication
};

```

(📂 **[state.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/state.ts)** L24‑L38)

## Practical Implementation Examples

### Automatic Session Handling

Standard page-level CDP calls require no manual session management:

```javascript
// Inside an ego-browser script
await page.waitForSelector('button#submit');   // Triggers ensureSession()
await cdp('Runtime.evaluate', { expression: 'document.title' });

```

### Forcing Reattachment After Navigation

To handle domain changes or tab switches, manually invalidate the cache:

```javascript
// Force a fresh attachment
await invalidateSession();      // Clears cached session
await ensureSession();          // Re-attaches to current active tab

```

### Handling Persistent Failures

Custom logic can distinguish between transient and permanent CDP errors:

```javascript
try {
  const result = await cdp('DOM.getDocument');
  console.log('DOM root:', result.root.nodeId);
} catch (e) {
  // Runtime already retried once; failure here indicates genuine protocol error
  console.error('Unable to talk to CDP:', e);
}

```

### Target Selection Across Multiple Tabs

When multiple tabs are open, pin the session to a specific target:

```javascript
setPreferredTarget('target-id-of-your-tab'); // Stored in state.preferredTargetId

```

## Summary

- **Time-bound caching**: Sessions remain valid for `SESSION_TTL_MS` (2 seconds) before requiring revalidation.
- **Lazy attachment**: `ensureSession()` creates connections only when needed, using promise deduplication to prevent race conditions.
- **Transparent recovery**: The runtime detects session loss via error message inspection and automatically reattaches once before surfacing errors.
- **Event-driven invalidation**: CDP lifecycle events trigger immediate cache clearing, ensuring no stale session IDs persist after target destruction.
- **Centralized state**: The `state` singleton in [`state.ts`](https://github.com/citrolabs/ego-lite/blob/main/state.ts) coordinates all session metadata across the browser runtime.

## Frequently Asked Questions

### How long does ego-browser cache a CDP session before requiring reattachment?

The runtime caches session IDs for **2 seconds** (`SESSION_TTL_MS`). After this freshness window expires, the next CDP request triggers `ensureSession()` to validate or recreate the connection. This TTL balances performance against Chrome's tendency to garbage collect idle sessions.

### What happens when a CDP session drops unexpectedly?

When `browserCdp()` detects a session-lost error (matching the `SESSION_LOST` regex), it automatically calls `invalidateSession()` to clear cached state, invokes `ensureSession()` to negotiate a new attachment, and retries the original command exactly once. This transparent recovery happens without throwing errors to the caller unless the reattachment also fails.

### How does ego-browser select which Chrome tab to attach to?

The `ensureSession()` implementation queries available targets via `ego.listTabs()`, then applies a selection hierarchy: first checking for a user-defined `preferredTargetId`, then falling back to the active tab, and finally defaulting to the last tab in the list. If the chosen `targetId` differs from the cached `sessionTargetId`, the runtime creates a fresh CDP session via `Target.attachToTarget`.

### Where does ego-browser store session state?

All session identifiers, timestamps, and inflight promises reside in a mutable singleton exported from [`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts). This shared state object includes `sessionId`, `sessionTargetId`, `sessionAt`, and `sessionInflight` fields that persist across function calls but remain scoped to the browser runtime instance.