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

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.

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, every snapshot triggers browserRefMap.clear() or instantiates a fresh RefMap:

// 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 stores structured metadata for each element:

// 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 normalizes ref strings:

// 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, resolution failures trigger automatic recovery:

// 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:

// 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

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

// ❌ 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 RefMap class and parseRef() utility
src/ref-state.ts Singleton browserRefMap instance, snapshot integration
src/element-resolver.ts Ref-to-CDP-handle resolution with auto-re-snapshot
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
  • 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 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.

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 includes a frameId field captured during snapshot creation. When resolving a ref, src/element-resolver.ts uses this field to attach to the correct CDP session for that frame, enabling seamless cross-frame automation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →