How Ego-Lite Buffers and Drains CDP Events: Inside the Browser Runtime

Ego-Lite maintains a bounded in-memory queue of up to 10,000 Chrome DevTools Protocol events in src/browser-runtime.ts, draining them atomically via drainBrowserEvents() while routing real-time matches to subscribers and predicate-based waiters.

The citrolabs/ego-lite repository provides a lightweight automation layer for Chrome DevTools Protocol (CDP) that must handle asynchronous browser events without data loss. Understanding how ego-lite buffers and drains CDP events is essential for building reliable automation scripts that capture every critical Page.lifecycleEvent or Runtime.consoleAPICalled signal.

The Bounded CDP Event Buffer

Buffer Configuration and Limits

At the core of the buffering system lies a simple but effective bounded array. According to the ego-lite source code in src/browser-runtime.ts, the runtime defines MAX_BUFFERED_EVENTS = 10000 around lines 6-9 to prevent unbounded memory growth. The system initializes three key data structures around lines 20-22: a mutable events = [] array that stores buffered events, an eventSubscribers registry for real-time listeners, and an eventWaiters list for promise-based predicates.

Message Classification in handleMessage()

When Chrome sends a JSON message over the CDP socket, the handleMessage() function (lines 31-45) immediately parses the payload. The function distinguishes between command responses and events by checking for the presence of an id field; messages containing an id are routed to pending request handlers, while those without an id are processed as generic CDP events subject to buffering logic.

Subscriber Routing and Fallback Buffering

For each incoming event, the runtime iterates over eventSubscribers (lines 77-84). If a subscriber’s method string matches the event’s data.method and the optional sessionId aligns, the listener is invoked immediately. However, if no subscriber consumes the event—or if the event is not a Page.screencastFrame that has already been processed—the event is pushed onto the events array (lines 85-89). This dual-path design ensures that transient events are captured even if no listener is currently registered.

Enforcing the 10K Event Limit

To prevent memory leaks during long-running automation sessions, the runtime enforces its size limit immediately after every push. Around lines 90-91, the code executes events.splice(0, events.length - MAX_BUFFERED_EVENTS), which removes the oldest excess entries from the front of the array. This guarantees that the buffer never grows beyond the configured 10,000-event threshold, regardless of event velocity.

Draining Mechanisms and Event Retrieval

Atomic Buffer Drain with drainBrowserEvents()

The public API drainBrowserEvents() provides an atomic snapshot of all buffered events. As implemented in lines 64-66, the function executes events.splice(0, events.length), which removes every item from the internal array in a single operation and returns them as a new array to the caller. This approach ensures that no events are lost between the read and clear operations, even under high concurrency.

Predicate-Based Waiting with waitForBrowserEvent()

For scenarios requiring specific events rather than bulk retrieval, waitForBrowserEvent() registers a predicate-based waiter. When a new event arrives, the runtime evaluates it against all registered eventWaiters (lines 73-84). If a waiter’s predicate function returns true, the promise resolves immediately with that event, and the waiter is removed from the list. Events that do not satisfy any waiter predicates remain in the buffer for later draining.

Real-Time Subscriptions via subscribeBrowserEvent()

In addition to buffering, the runtime supports persistent subscriptions through subscribeBrowserEvent(), exposed via src/helpers.ts. Subscribers registered with this API receive events in real-time as they arrive, bypassing the buffer entirely. Only events that fail to match any subscriber fall back to the buffered events array, creating a tiered consumption model that prioritizes immediate handling while preserving data for later inspection.

Implementation Examples

Draining All Pending CDP Events

To retrieve and clear all buffered events from a running session:

import { drainBrowserEvents } from "ego-browser/src/browser-runtime.js";

async function logPendingEvents() {
  const pending = drainBrowserEvents();   // empties the buffer atomically
  console.log("Pending CDP events:", pending);
}

This call returns an array of all events buffered since the last drain, including any Target.attachedToTarget or Network.responseReceived messages that were not consumed by subscribers.

Waiting for Specific Navigation Events

To block execution until a specific lifecycle event occurs:

import { waitForBrowserEvent } from "ego-browser/src/browser-runtime.js";

async function waitForNavigation() {
  const navigationEvent = await waitForBrowserEvent(
    (e) => e.method === "Page.lifecycleEvent" && e.params?.name === "load"
  );
  console.log("Page loaded:", navigationEvent);
}

The predicate resolves the promise the moment a matching Page.lifecycleEvent appears, checking both new incoming events and those already present in the buffer.

Subscribing to Real-Time Console Output

To capture console messages continuously without buffering delays:

import { subscribeBrowserEvent } from "ego-browser/src/browser-runtime.js";

const unsubscribe = subscribeBrowserEvent(
  "Runtime.consoleAPICalled",
  undefined,        // listen on any session
  (event) => console.log("Console:", event.params.args)
);

// Later, when you no longer need the subscription:
unsubscribe();

This subscriber receives Runtime.consoleAPICalled events immediately as they arrive from Chrome, preventing them from ever reaching the bounded buffer.

Summary

  • Bounded memory safety: The 10,000-event hard limit in MAX_BUFFERED_EVENTS prevents memory leaks via automatic truncation of the oldest entries.
  • Atomic draining: drainBrowserEvents() uses a single splice operation to guarantee no event loss during retrieval.
  • Tiered consumption: The system prioritizes real-time subscribers and predicate waiters, falling back to the buffer only for unconsumed events.
  • Flexible retrieval: Developers can choose between bulk draining, specific waiting, or continuous subscription based on their automation needs.

Frequently Asked Questions

What happens when the CDP event buffer reaches 10,000 entries?

When the events array exceeds MAX_BUFFERED_EVENTS, the runtime removes the oldest excess entries using events.splice(0, events.length - MAX_BUFFERED_EVENTS). This ensures the buffer never grows unboundedly, protecting against memory exhaustion during long-running browser automation sessions.

How does ego-lite distinguish between CDP command responses and events?

The handleMessage() function checks for the presence of an id field in the incoming JSON. Messages containing an id are treated as responses to pending CDP commands and routed to their respective request handlers, while messages lacking an id are processed as asynchronous events subject to buffering and subscriber routing.

What is the difference between draining and waiting for events?

Draining via drainBrowserEvents() immediately empties the entire buffer and returns all captured events as an array, clearing the internal state. In contrast, waitForBrowserEvent() registers a predicate function that resolves when a specific event arrives, without necessarily removing other events from the buffer or altering the queue state beyond the matching entry.

Can multiple subscribers receive the same CDP event simultaneously?

Yes, the runtime iterates over all eventSubscribers and invokes every listener whose method and optional sessionId match the incoming event. If no subscribers match, the event falls back to the buffer; there is no exclusive consumption, allowing multiple automation components to react to the same browser signal independently.

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 →