How Ego-Lite Uses Backend Node IDs to Persist Refs Across Heredoc Rounds
Ego-lite's snapshot/ref system stores Chrome DevTools Protocol backend node IDs in a persistent map, enabling agents to reference DOM elements using short refs like @21 across multiple heredoc rounds without rebuilding selectors.
The ego-lite browser automation library provides a lightweight mechanism for agents to interact with web pages through "heredoc" script blocks that execute in fresh JavaScript contexts. Central to this design is a snapshot/ref system that maps human-readable refs to stable backend node IDs, allowing element references to survive across execution rounds.
How Backend Node IDs Enable Cross-Round Ref Persistence
Chrome DevTools Protocol assigns each Accessibility (AX) node a numeric backend node ID that remains stable for the lifetime of the page session. Ego-lite leverages this identifier rather than CSS selectors or XPath expressions, which can break when the DOM mutates.
The RefMap: Storing Backend Node IDs
In src/ref-map.ts, the RefMap class maintains the core data structure:
// Simplified from src/ref-map.ts
class RefMap {
private map = new Map<string, RefEntry>();
add(ref: string, entry: { backendNodeId: number; role: string; name?: string }) {
this.map.set(ref, entry);
}
get(ref: string): RefEntry | undefined {
return this.map.get(ref);
}
}
Each ref string (e.g., "21") maps to a RefEntry containing the backendNodeId along with accessibility metadata. When a snapshot is taken, the system enumerates all AX nodes and populates this map.
Ensuring Snapshot Availability Before Ref Resolution
The ref-state.ts module implements lazy snapshot initialization:
// From src/ref-state.ts
async function ensureRefMapForRef(ref: string): Promise<void> {
if (browserRefMap.size === 0 && looksLikeRef(ref)) {
// Trigger registered snapshot function
await registeredSnapshotFn();
}
}
This function checks browserRefMap (the singleton RefMap instance stored in src/state.ts). If the map is empty and the argument matches ref syntax (starts with @), it automatically invokes the registered snapshot function. This ensures the first ref usage in any heredoc round triggers a fresh snapshot only when necessary.
Resolving Refs to CDP Commands
Once populated, element-resolver.ts converts refs into actionable CDP calls:
// Conceptual flow from src/element-resolver.ts
function resolveRef(ref: string) {
const entry = browserRefMap.get(ref.replace('@', ''));
if (!entry) throw new Error(`Unknown ref: ${ref}`);
// Forward backendNodeId to CDP with objectGroup: "ego-browser"
return cdp.send('DOM.describeNode', {
backendNodeId: entry.backendNodeId,
objectGroup: 'ego-browser'
});
}
The resolver extracts the stored backendNodeId and uses it directly in CDP commands like DOM.describeNode or accessibility queries, bypassing selector-based resolution entirely.
Why This Design Survives Heredoc Rounds
Each heredoc round executes in a fresh V8 context with no preserved JavaScript state. However, ego-lite's architecture separates concerns:
- Ephemeral context: Variables and functions defined in one heredoc block do not persist.
- Persistent runtime: The
browserRefMapsingleton insrc/state.tslives in the Node.js host process, surviving context resets.
// First heredoc round — snapshot and store ref
await ego.browser.snapshot(); // populates RefMap with @1, @2, @3...
const button = await ego.browser.$('button.save'); // resolves, adds to map
await ego.browser.click('@12'); // uses stored backendNodeId
// --- Fresh heredoc round, new V8 context ---
// RefMap still populated from previous round
await ego.browser.click('@12'); // no snapshot needed, resolves immediately
The backendNodeId stability guarantees remain valid as long as:
- The page has not navigated to a different URL.
- The specific DOM element has not been removed.
When navigation occurs, the ref-state.ts logic detects an empty map on first ref access and triggers a new snapshot automatically.
Snapshot Refresh Strategies
Ego-lite supports explicit and implicit snapshot refresh:
| Strategy | Trigger | Use Case |
|---|---|---|
| Explicit | Manual ego.browser.snapshot() call |
Force refresh after known DOM mutations |
| Implicit | ensureRefMapForRef detects empty map |
Automatic recovery after navigation or map clearing |
// Explicit refresh after heavy DOM manipulation
await ego.browser.click('#load-more');
await ego.browser.snapshot(); // repopulate RefMap
await ego.browser.click('@45'); // new ref from fresh snapshot
// Implicit refresh — navigation clears map automatically
await ego.browser.goto('https://example.com/page2');
await ego.browser.click('@3'); // triggers ensureRefMapForRef → new snapshot
Key Files in the Ref Persistence System
src/ref-map.ts—RefMapclass withadd/getmethods for backend node ID storage.src/ref-state.ts— SingletonbrowserRefMap,ensureRefMapForReflazy initialization, and snapshot callback registration.src/element-resolver.ts— Ref-to-backendNodeId lookup and CDP command forwarding.src/browser-runtime.ts— Orchestrates snapshot execution viaregisterSnapshotForRefRefresh.src/state.ts— Host-process singleton holding the persistentbrowserRefMapinstance.
Summary
- Backend node IDs provide stable element identifiers across DOM mutations and heredoc context resets.
- RefMap stores the
backendNodeIdfor each ref in a host-process singleton that survives V8 context disposal. - Lazy initialization via
ensureRefMapForRefminimizes snapshot overhead by only refreshing when the map is empty. - Direct CDP resolution eliminates selector recomputation and reduces brittleness in dynamic web applications.
Frequently Asked Questions
What happens if I use a ref after the element was deleted from the DOM?
The backendNodeId will be invalid, and CDP commands will return an error. Ego-lite does not automatically validate element existence before command execution; you must handle DOM.describeNode failures or trigger a fresh snapshot to obtain valid refs for the current DOM state.
How does ego-lite distinguish between refs and regular selectors?
The looksLikeRef function in ref-state.ts checks if the string starts with @ followed by numeric digits. Strings like @21 are treated as refs, while button.save or [data-testid="submit"] pass through to standard selector resolution.
Why store backendNodeId instead of a CSS selector path?
Selectors break when elements move in the DOM hierarchy, receive new classes, or when dynamic lists reorder. The backendNodeId is session-stable and mutation-resistant, providing reliable re-identification even as the page structure evolves between heredoc rounds.
Can I manually clear the RefMap to force a snapshot?
While ref-state.ts manages the map internally, calling ego.browser.snapshot() explicitly clears and repopulates the map. Direct manipulation of browserRefMap is not exposed in the public API to prevent inconsistent state between the map and actual CDP snapshots.
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 →