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

> Discover how Ego-Lite's RefMap uses backendNodeId for stable element references across snapshots. Learn how it ensures valid refs with numeric IDs and role-based fallbacks for stale nodes.

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

---

**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`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts), the `browserSnapshotRefsToRefMap` function populates the global ref map immediately after every snapshot.

```ts
browserSnapshotRefsToRefMap(browserRefMap, snapshot.refs);

```

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

```ts
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`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) retrieves the entry from `RefMap` and prefers the stored `backendNodeId` for direct CDP access.

```ts
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`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) catches the error and falls back to **`findBackendNodeIdByRoleName`**.

```ts
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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) triggers an on-demand refresh.

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

```

The **`ensureRefMapForRef`** function in [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) calls `ensureRefMapForRef`. This triggers `snapshotImpl()` to generate a new snapshot and repopulate the map before resolution proceeds.