How Lightpanda's Notification System Handles Page Lifecycle Events

Lightpanda isolates each CDP connection in its own Notification instance that dispatches page lifecycle events through doubly-linked listener lists, guaranteeing that events like page creation, navigation, and frame loading are delivered only to the client that owns the session.

The lightpanda-io/browser project implements a dedicated notification subsystem to manage page lifecycle events across Chrome DevTools Protocol (CDP) sessions. At the core of this system is src/Notification.zig, which provides the registry and dispatch logic that connects browser internals to CDP clients. Understanding how this mechanism tracks page creation, navigation, and removal is essential for developers extending Lightpanda's automation capabilities.

Architecture of the Notification System

Per-Session Isolation

Lightpanda creates a unique Notification instance per CDP session during Session.init, ensuring complete isolation between clients. Each Notification struct maintains an EventListeners union that holds doubly-linked lists for every hard-coded lifecycle event (see lines 69-78 in src/Notification.zig). This design prevents cross-contamination where one CDP client would receive events from another client's pages.

Listener Storage and Tracking

The system employs a dual-structure design for managing subscriptions:

  • Doubly-linked lists stored in the EventListeners struct maintain the ordered set of callbacks for each event type (e.g., .page_created, .page_navigate).
  • A hash-map named listeners maps receiver pointer addresses to ArrayList collections of Listener objects.

This architecture enables O(1) bulk unregistration via unregisterAll while preserving deterministic, ordered dispatch through the linked lists.

Page Lifecycle Event Flow

Lightpanda emits specific events at distinct phases of a page's existence. The following table maps each phase to its source location and payload type:

  • Page creation — Session.createPage calls self.notification.dispatch(.page_created, page) immediately after Page.init returns (Session.zig lines 48-49). Payload: *Page.
  • Page removal — Session.removePage emits .page_remove with an empty struct {} before the page object is torn down (Session.zig lines 53-55).
  • Navigation start — Page.navigate dispatches .page_navigate with a *Notification.PageNavigate payload before any HTTP request is sent (Page.zig lines 76-82).
  • Navigation finish — The same navigation function emits .page_navigated after document injection or once the HTTP response is fully received (Page.zig lines 92-99). Payload: *Notification.PageNavigated.
  • Frame creation — Page.frameCreated triggers .page_frame_created with frame metadata (Page.zig lines 1072-1074). Payload: *Notification.PageFrameCreated.
  • Network idle — Page.networkIdle and Page.networkAlmostIdle emit .page_network_idle and .page_network_almost_idle respectively for load-state tracking.

Core Implementation Details

Registering Event Listeners

The register function (lines 98-124 in src/Notification.zig) accepts a comptime EventType, a receiver pointer, and a callback function matching the event's signature:

pub fn register(self: *Notification, comptime event: EventType,
                receiver: anytype, func: EventFunc(event)) !void { … }

Internally, the function allocates a Listener from a memory pool, appends it to the appropriate per-event doubly-linked list, and records the receiver-to-listener mapping in the hash-map. This allows the system to track every callback belonging to a specific CDP client.

Dispatching Events

The dispatch routine (lines 56-76) iterates the linked list corresponding to the comptime event type, casting stored function pointers back to their concrete signatures before invocation:

pub fn dispatch(self: *Notification, comptime event: EventType,
               data: ArgType(event)) void { … }

The system tolerates listener errors by logging them without propagating exceptions, ensuring a single faulty handler cannot block the notification chain for other registered listeners.

Unregistering Listeners

Cleanup supports both targeted and bulk removal:

  • unregister removes a specific listener from a single event list.
  • unregisterAll (starting at line 125) looks up the receiver in the hash-map, removes all associated nodes from their respective event lists, destroys the listener objects via the memory pool, and purges the hash-map entry.

This prevents memory leaks when a CDP connection closes and its receiver object is destroyed.

Working with the Notification API

Register a Listener for Navigation Events

The following pattern demonstrates how a CDP handler or test suite might subscribe to page navigation:

// Somewhere in a CDP handler or test code
var notifier = try Notification.init(allocator);
defer notifier.deinit();

const client = struct {
    var navigateCount: usize = 0;

    fn onNavigate(ptr: *anyopaque, data: *const Notification.PageNavigate) !void {
        const self: *@This() = @ptrCast(@alignCast(ptr));
        self.navigateCount += data.timestamp;
    }
};

var testClient = client{};
try notifier.register(.page_navigate, &testClient, client.onNavigate);

The registration adds client.onNavigate to the .page_navigate list and records the receiver (&testClient) in the hashmap for later cleanup.

Dispatch a Navigation Start (Internal Usage)

As implemented in src/browser/Page.zig, the browser kernel dispatches lifecycle events directly to the session's notification instance:

session.notification.dispatch(.page_navigate, &.{
    .frame_id = self._frame_id,
    .req_id    = req_id,
    .opts      = opts,
    .url       = self.url,
    .timestamp = timestamp(.monotonic),
});

All registered page_navigate listeners are invoked immediately in the order they were registered.

Unregister All Listeners for a Receiver

When a CDP connection closes, bulk cleanup prevents dangling pointers:

notifier.unregisterAll(&testClient);

The hashmap entry is removed and all associated listener nodes are destroyed, releasing memory back to the pool.

Summary

  • Per-session isolation: Lightpanda creates one Notification instance per CDP session to guarantee client isolation, stored in Session.zig.
  • Dual data structures: Doubly-linked lists manage ordered dispatch while a hash-map enables efficient bulk unregistration via unregisterAll.
  • Complete lifecycle coverage: Events span page creation, removal, navigation start/finish, frame creation, and network idle states.
  • Fault tolerance: The dispatch loop logs listener errors without halting execution, ensuring robust event delivery even with faulty handlers.
  • Source locations: Dispatch calls originate from src/browser/Session.zig (creation/removal) and src/browser/Page.zig (navigation and frames), with consumption typically occurring in src/cdp/cdp.zig.

Frequently Asked Questions

How does Lightpanda prevent event leaks between CDP clients?

By instantiating a unique Notification object for each Session during initialization, the browser ensures that listener registrations are scoped to a single connection. When the connection terminates, unregisterAll cleans up every listener associated with that specific receiver pointer, preventing any stray callbacks from surviving beyond the client's lifecycle.

What happens if a page lifecycle listener throws an error?

The dispatch implementation in src/Notification.zig wraps each listener invocation in an error-handling block that logs failures without propagating them. This design prevents a single faulty callback from interrupting the notification chain for other registered handlers on the same event.

Which source files are responsible for emitting page lifecycle events?

src/browser/Session.zig handles .page_created and .page_remove, while src/browser/Page.zig emits the majority of events including navigation start/finish, frame creation, and network idle states. CDP handlers in src/cdp/cdp.zig (line 438) consume these events to send corresponding protocol messages to the client.

Can I register multiple listeners for the same event type on one notification instance?

Yes. The doubly-linked list structure within the EventListeners struct supports unlimited listeners per event type. Each call to register appends a new node to the event's list, and dispatch iterates through all nodes in registration order, guaranteeing every handler receives the event.

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 →