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 backendrole: The accessibility (AX) role of the elementname: The accessible namenth: Optional index for disambiguating identical elementsframeId: 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
frameIdfor correct cross-frame CDP targeting - Use stable locators for durability: Rely on
loc=css:...orloc=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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →