# How Lightpanda's Notification System Handles Page Lifecycle Events

> Discover how Lightpanda's notification system manages page lifecycle events like creation and navigation, ensuring accurate delivery to the correct client sessions.

- Repository: [Lightpanda/browser](https://github.com/lightpanda-io/browser)
- Tags: internals
- Published: 2026-03-14

---

**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:

```zig
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:

```zig
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:

```zig
// 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:

```zig
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:

```zig
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.