# How Ego-Lite Uses Backend Node IDs to Persist Refs Across Heredoc Rounds

> Discover how Ego-lite's snapshot/ref system uses backendNodeId to persist DOM element references across heredoc rounds, simplifying agent interactions without rebuilding selectors.

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

---

**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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts), the `RefMap` class maintains the core data structure:

```typescript
// 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`](https://github.com/citrolabs/ego-lite/blob/main/ref-state.ts) module implements **lazy snapshot initialization**:

```typescript
// 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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/element-resolver.ts) converts refs into actionable CDP calls:

```javascript
// 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 `browserRefMap` singleton in [`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts) lives in the **Node.js host process**, surviving context resets.

```javascript
// 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`](https://github.com/citrolabs/ego-lite/blob/main/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 |

```javascript
// 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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts)** — `RefMap` class with `add`/`get` methods for backend node ID storage.
- **[`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts)** — Singleton `browserRefMap`, `ensureRefMapForRef` lazy initialization, and snapshot callback registration.
- **[`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts)** — Ref-to-backendNodeId lookup and CDP command forwarding.
- **[`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts)** — Orchestrates snapshot execution via `registerSnapshotForRefRefresh`.
- **[`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts)** — Host-process singleton holding the persistent `browserRefMap` instance.

## Summary

- **Backend node IDs** provide stable element identifiers across DOM mutations and heredoc context resets.
- **RefMap** stores the `backendNodeId` for each ref in a host-process singleton that survives V8 context disposal.
- **Lazy initialization** via `ensureRefMapForRef` minimizes 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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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.