How the Ref-Map Uses Backend Node IDs for Stable Element References Across Snapshots

Ego-Lite's RefMap stores Chrome DevTools Protocol backendNodeId values alongside element metadata to create short numeric refs that remain valid across page snapshots, falling back to role-based lookups only when nodes become stale.

In the citrolabs/ego-lite browser runtime, the ref-map uses backendNodeId for stable element references across snapshots by maintaining a lightweight registry that survives DOM updates and navigation. Each snapshot generates a fresh mapping between human-readable refs like @21 and the CDP backend node identifiers, allowing helper functions to resolve elements directly without re-querying the page.

How the RefMap Stores Backend Node IDs During Snapshots

In src/browser-runtime.ts, the browserSnapshotRefsToRefMap function populates the global ref map immediately after every snapshot.

browserSnapshotRefsToRefMap(browserRefMap, snapshot.refs);

Inside this function, every element reference discovered by the snapshot is registered with refMap.add:

refMap.add(refId, backendNodeId, role, name, nth, frameId);

The backendNodeId is the Chrome DevTools Protocol "backend DOM node identifier." It uniquely identifies the exact DOM node for the current CDP session, making it the primary handle for subsequent element operations. The ref map also stores the element's role, name, optional nth index, and frameId to support fallback resolution later.

Resolving Elements Directly via Stored Backend Node ID

When a helper receives a selector formatted as a ref such as @21, src/element-resolver.ts retrieves the entry from RefMap and prefers the stored backendNodeId for direct CDP access.

const refId = parseRef(selectorOrRef);
const entry = refMap.get(refId);
if (entry?.backendNodeId != null) {
  const result = await send(cdp, "DOM.getBoxModel",
    { backendNodeId: entry.backendNodeId }, sessionId);
  return boxModelCenter(result.model);
}

Functions like resolveElementCenter and resolveElementObjectId follow this pattern. If entry.backendNodeId is defined, the resolver calls DOM.getBoxModel for geometry or DOM.resolveNode for an object ID. This bypasses re-searching the page and yields deterministic, fast lookups because the identifier points straight to the node known by the browser's backend.

Handling Stale Backend Nodes with Role-Name Fallback

A stored backendNodeId can become invalid after navigation, re-rendering, or any DOM update that destroys the original node. When a CDP call throws because the node no longer exists, the resolver in src/element-resolver.ts catches the error and falls back to findBackendNodeIdByRoleName.

try {
  // Attempt using stored backendNodeId (fast path)
} catch (e) {
  // Stale node → look it up again by role/name
  const backendNodeId = await findBackendNodeIdByRoleName(
    cdp, sessionId, entry.role, entry.name, entry.nth, entry.frameId);
}

This fallback leverages the metadata captured during the snapshot—role, name, nth, and frameId—to locate the replacement node. The system therefore prioritizes the speed and stability of the original backendNodeId while guaranteeing that refs remain usable even when the underlying DOM changes.

Automatic Ref Map Refresh Between Snapshots

The RefMap is cleared after each snapshot to prevent stale data from accumulating. If script execution attempts to resolve a ref while the map is empty, src/ref-state.ts triggers an on-demand refresh.

if (typeof selectorOrRef === "string" && parseRef(selectorOrRef) && browserRefMap.map.size === 0) {
  await ensureRefMapForRef(selectorOrRef); // triggers snapshotImpl()
}

The ensureRefMapForRef function in src/ref-state.ts calls snapshotImpl() to generate a new snapshot and repopulate browserRefMap before the ref is resolved. This lazy-refresh pattern ensures that refs are always backed by current snapshot data without requiring manual synchronization.

Summary

  • src/browser-runtime.ts feeds each snapshot's element list into browserSnapshotRefsToRefMap, which registers every ref with its backendNodeId, role, name, and frame metadata.
  • src/element-resolver.ts resolves refs by querying CDP methods like DOM.getBoxModel directly against the stored backendNodeId for fast, deterministic access.
  • If a CDP call fails because the node is stale, the runtime falls back to findBackendNodeIdByRoleName using the captured metadata.
  • src/ref-state.ts clears the map after each snapshot and lazily refreshes it via ensureRefMapForRef and snapshotImpl whenever an empty map is queried.

Frequently Asked Questions

What is a backendNodeId in the context of Ego-Lite?

A backendNodeId is the Chrome DevTools Protocol identifier assigned to a DOM node for the duration of a CDP session. In Ego-Lite, it acts as the primary handle inside RefMap to address elements directly without re-scanning the page, as implemented in src/element-resolver.ts.

Why does the RefMap clear after every snapshot?

The map is cleared to avoid retaining identifiers for nodes that may have been destroyed or recreated by navigation or DOM updates. By repopulating fresh data via browserSnapshotRefsToRefMap in src/browser-runtime.ts, Ego-Lite guarantees that every ref is backed by a current backendNodeId from the latest snapshot.

How does Ego-Lite recover when a backendNodeId becomes stale?

When a CDP method such as DOM.getBoxModel throws because the stored node no longer exists, src/element-resolver.ts catches the failure and invokes findBackendNodeIdByRoleName. This function searches the live DOM using the original role, name, nth, and frameId to discover a replacement node.

Can refs be resolved if the RefMap is currently empty?

Yes. If a script attempts to use a ref while browserRefMap.map.size is zero, src/ref-state.ts calls ensureRefMapForRef. This triggers snapshotImpl() to generate a new snapshot and repopulate the map before resolution proceeds.

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 →