# How the Ref-Map Works Across Multiple Snapshots in ego-browser

> Explore how the ref-map in ego-browser reconstructs itself on each snapshot for efficient performance. Learn about stale ref detection and auto-resnapshotting.

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

---

**The ref-map in ego-browser uses a transient RefMap instance that is cleared and rebuilt on every snapshot, with automatic re-snapshotting triggered when stale refs are detected.**

The `ego-browser` library, part of the citrolabs/ego-lite repository, provides browser automation agents with a lightweight way to reference DOM elements. Understanding how the ref-map persists—or rather, doesn't persist—across snapshots is critical for writing reliable agent scripts. This article explains the exact mechanism using the actual source code implementation.

## What Is the Ref-Map in ego-browser

The **ref-map** is a runtime mapping layer that translates short "ref" strings (like `@21`) into the underlying browser node information needed for CDP (Chrome DevTools Protocol) operations. Each ref points to a `RefMapEntry` containing:

- `backendNodeId`: The numeric ID assigned by Chrome's DOM backend
- `role`: The accessibility (AX) role of the element
- `name`: The accessible name
- `nth`: Optional index for disambiguating identical elements
- `frameId`: The frame identifier for cross-frame element resolution

The ref-map lives in a singleton instance called `browserRefMap`, defined and managed in [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts).

## Why the Ref-Map Is Reset on Every Snapshot

Backend node IDs in Chrome are **scoped to a single CDP session**. After navigation, frame updates, or significant DOM changes, Chrome can reassign these IDs arbitrarily. A ref like `@21` that pointed to a button on the previous page might point to an image—or nothing at all—after navigation.

To prevent silent failures, `ego-browser` takes a **transient map approach**: the RefMap is deliberately short-lived, tied to a single snapshot's validity window.

## Step-by-Step: Ref-Map Lifecycle Across Snapshots

### 1. Snapshot Creation Clears the Map

In [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts), every snapshot triggers `browserRefMap.clear()` or instantiates a fresh `RefMap`:

```typescript
// src/ref-state.ts - simplified core logic
export function createSnapshot() {
  browserRefMap.clear(); // Discard all previous refs
  
  // Traverse current DOM and populate fresh refs...
  for (const node of accessibleNodes) {
    const refId = generateRefId();
    browserRefMap.add(refId, node.backendNodeId, node.role, node.name, node.nth, node.frameId);
  }
}

```

This guarantees no stale entries survive into the new snapshot.

### 2. Ref Population Stores Complete Node Context

The `add()` method in [`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) stores structured metadata for each element:

```typescript
// src/ref-map.ts
export class RefMap {
  private map = new Map<number, RefMapEntry>();
  
  add(refId: number, backendNodeId: number, role: string, name: string, nth?: number, frameId?: string) {
    this.map.set(refId, { backendNodeId, role, name, nth, frameId });
  }
  
  get(refId: number): RefMapEntry | undefined {
    return this.map.get(refId);
  }
  
  clear(): void {
    this.map.clear();
  }
}

```

### 3. Ref Lookup Parses Multiple Input Formats

The `parseRef()` helper in [`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) normalizes ref strings:

```typescript
// src/ref-map.ts
export function parseRef(ref: string | number): number {
  if (typeof ref === 'number') return ref;
  // Handle "@21", "ref=21", or "21"
  const clean = ref.replace(/^[@#]?ref[=:]?/i, '').replace(/^@/, '');
  const num = parseInt(clean, 10);
  if (isNaN(num)) throw new Error(`Invalid ref format: ${ref}`);
  return num;
}

```

### 4. Automatic Re-Snapshot on Missing Ref

In [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts), resolution failures trigger automatic recovery:

```typescript
// src/element-resolver.ts
export async function resolveRef(ref: string): Promise<ElementHandle> {
  const id = parseRef(ref);
  let entry = browserRefMap.get(id);
  
  if (!entry) {
    // Map is stale or empty → fresh snapshot required
    await snapshot(); // Auto-triggered re-snapshot
    entry = browserRefMap.get(id);
  }
  
  if (!entry) {
    throw new ElementNotFoundError(ref, 'permanent'); // Ref truly invalid
  }
  
  return createHandle(entry);
}

```

This **defensive design** ensures agents never operate on outdated mappings.

### 5. Frame-Aware Resolution for Cross-Frame Operations

The stored `frameId` enables correct CDP session attachment:

```typescript
// Using frameId from ref-map entry
const { backendNodeId, frameId } = browserRefMap.get(refId)!;
const session = frameId 
  ? await getFrameSession(frameId) 
  : mainSession;

await session.send('DOM.focus', { backendNodeId });

```

## Practical Code Examples

### Resolving a Ref and Performing an Action

```typescript
import { parseRef, browserRefMap, snapshot, cdp } from 'ego-browser';

async function clickElement(refString: string) {
  // Parse "@42" → 42
  const refId = parseRef(refString);
  
  // Retrieve node info from current snapshot's map
  const entry = browserRefMap.get(refId);
  if (!entry) {
    // Automatic re-snapshot would have occurred in higher-level APIs
    throw new Error(`Ref ${refString} not found in current snapshot`);
  }
  
  // Use backendNodeId for CDP operation
  const { backendNodeId, frameId } = entry;
  const target = frameId 
    ? `[data-frame="${frameId}"] [data-backend-id="${backendNodeId}"]`
    : `[data-backend-id="${backendNodeId}"]`;
    
  await cdp('Runtime.evaluate', {
    expression: `document.querySelector('${target}').click()`
  });
}

```

### Handling Navigation in Agent Scripts

```typescript
// ❌ INCORRECT: Refs don't survive navigation
const buttonRef = await findRef('role:button[name="Submit"]');
await navigate('/new-page');
await click(buttonRef); // May click wrong element or throw

// ✅ CORRECT: Re-locate after navigation
await navigate('/new-page');
const buttonRef = await findRef('role:button[name="Submit"]'); // Fresh snapshot
await click(buttonRef);

```

## Key Implementation Files

| File | Purpose |
|------|---------|
| [`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) | `RefMap` class and `parseRef()` utility |
| [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) | Singleton `browserRefMap` instance, snapshot integration |
| [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) | Ref-to-CDP-handle resolution with auto-re-snapshot |
| [`src/snapshot.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/snapshot.ts) | Snapshot orchestration that triggers map reset |

## Summary

- **Transient by design**: Each snapshot creates an isolated RefMap; refs don't persist across snapshots
- **Automatic recovery**: Missing refs trigger automatic re-snapshot via [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts)
- **Multiple input formats**: `parseRef()` handles `@21`, `ref=21`, `21`, and numeric inputs
- **Frame-aware**: Entries store `frameId` for correct cross-frame CDP targeting
- **Use stable locators for durability**: Rely on `loc=css:...` or `loc=role:...` for long-running scripts; reserve refs for short-lived interactions within a single snapshot

## Frequently Asked Questions

### How long does a ref remain valid in ego-browser?

A ref remains valid only until the next snapshot. Since snapshots occur automatically after navigation, DOM mutations, or explicit calls, refs are effectively **single-snapshot identifiers**. According to the [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) implementation, `browserRefMap.clear()` executes before every new snapshot, immediately invalidating all previous refs.

### What happens if I use a stale ref after navigation?

The runtime detects the missing entry in `browserRefMap` and automatically triggers `snapshot()` to rebuild the map. If the element still exists at the same backend node ID, the operation succeeds; otherwise, you'll receive a permanent resolution error. This safety mechanism is implemented in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts).

### Can I force a ref to persist across snapshots?

No—this would violate the design constraints of Chrome's CDP backend node IDs, which are **not stable across sessions**. The transient RefMap design in `ego-browser` is a deliberate safety measure. For cross-snapshot element identification, use stable locators like `loc=css:#submit-button` or `loc=role:button[name="Confirm"]` instead.

### How does ego-browser handle refs inside iframes?

The `RefMapEntry` stored in [`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) includes a `frameId` field captured during snapshot creation. When resolving a ref, [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) uses this field to attach to the correct CDP session for that frame, enabling seamless cross-frame automation.