How We Built MultiClient into Lightpanda

Nikolay Govorov

Nikolay Govorov

Software Engineer

How We Built MultiClient into Lightpanda

TL;DR

Lightpanda now handles multiple concurrent CDP (Chrome DevTools Protocol) connections in a single process. Each connection gets a dedicated OS thread and a fully isolated browser instance. When the client disconnects, everything cleans up automatically.

The Problem with One Process, One Connection

Until this landed, one Lightpanda instance meant one CDP client.

If you’re running a large scale web automation, best practice is to run dozens of parallel sessions. If you’re building AI agents, each agent needs its own browser context. Previously, the only way to scale was to manage multiple Lightpanda processes. Given Lightpanda’s performance overhead is significantly lower than Chrome, this wasn’t as much of a blocker as it may have sounded, but it still meant process orchestration, port management, and more operational overhead.

Running each connection on its own thread required working through several architectural decisions.

Isolation Layer

Any modern browser consists of two layers: the browser engine (Blink in Chromium, WebKit in Safari, Gecko in Firefox) and the browser itself. The engine handles exactly one tab (even a frame will often be represented by a separate child engine instance), while the browser acts as a hypervisor: it maintains a pool of tabs and implements common functions like the UI or synchronization between computers.

This separation is clearly reflected in the process structure: browsers launch each tab as a separate system process in a sandbox, and an additional browser process for orchestration (this may remind you of the Nginx process model with a master and a worker pool). This approach allows for simpler code: a crash or vulnerability in one tab will crash the child process, but the browser will continue to run.

The problem with this approach is resource consumption. Each tab is a separate program, with separate Blink and V8 instances, complex asynchronous IPC for communicating with the main process, and sandbox overhead.

Decision 1: One Process Architecture

To further conserve resources and scale horizontally, Lightpanda offers a single-system process architecture. Each CDP connection is a system thread that services its own browser instance (this will remind older developers of classic WebKit).

By abandoning isolation, you complicate your code, but gain several important advantages:

  • Starting a new connection is reduced to selecting a free thread from the pool, instead of launching a full-fledged system process.
  • A shared address makes state sharing straightforward. For example, for the HTTP cache in Chromium, you need a network process (yes, another one), but within a single process, you can simply share a pointer.
  • Resource control: process termination is guaranteed to collect all resources (and you won’t leave zombies in the wild).
  • Ease of debugging and monitoring: no IPC, message serialization, or asynchronous messaging. You simply call functions.

The obvious tradeoff of less isolation would be critical for a desktop browser, but not for servers: Lightpanda is already running in a container/vm/jail within your server infrastructure. Potential failures will be handled by existing tools, and another sandbox doesn’t offer a particular advantage.

Decision 2: What Connections Can Share

The key benefit of the shared process is the ability to easily reuse resources between connections. The more you can share, the faster the connection starts and the less RAM you use.

  • The V8 environment is the heaviest part of the process. We use a common Platform to save on preparing the environment for a new connection.
  • The ArenaPool is a pool of memory arenas reused across page lifecycles. Lightpanda relies heavily on arenas to reduce allocations (and heap fragmentation), so the arena has also been modified to work in a multi-threaded environment.
  • The robots cache stores parsed robots.txt data shared across requests. Same situation: reads and writes from multiple threads needed protection.
  • The notification and telemetry system had a nested notification design that wasn’t thread-safe. The previous approach let telemetry hook into individual notifications through a shared interceptor. We simplified it: telemetry events are now triggered directly rather than through a notification chain. It removed a layer of indirection and made the threading story cleaner.

The network stack is the most difficult part to reuse. The current implementation supports an independent network client for each connection (with its own event loop on each thread).

This is reliable and simplified the migration, but there is still room for optimization here: shared connection pooling, TLS state, HTTP caching, and much more, which we will continue to work on in future versions.

Decision 3: The V8 Isolate Problem

V8’s Isolate is the sandboxed environment where JavaScript runs. It was designed as a single-threaded creature. The Deno team documented this clearly in 2020 when they moved away from v8::Locker, V8’s mechanism for sharing an Isolate across threads.

We use V8 Isolate for each connection, separating only the Platform. This comes at the cost of creating a new Isolate, but it allows us to clearly separate different tabs.

The catch is startup time. Creating a fresh V8 Isolate is expensive. In the future, we might consider a reusable Isolate buffer, but this would require careful cleanup to avoid context pollution and associated potential leaks.

What You Get Today

Each CDP connection supports one browser context and one page at a time. That covers the standard usage pattern for Puppeteer and Playwright against a remote endpoint.

Connect with Puppeteer:

import puppeteer from 'puppeteer-core'; const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://127.0.0.1:9222' }); const context = await browser.createBrowserContext(); const page = await context.newPage(); await page.goto('https://example.com'); await page.close(); await context.close(); await browser.disconnect();

Connect with Playwright:

import { chromium } from 'playwright-core'; const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222'); const context = await browser.newContext(); const page = await context.newPage(); await page.goto('https://example.com'); await page.close(); await context.close(); await browser.close();

If you run both scripts at the same time against the same Lightpanda process, each gets a fully isolated browser and nothing leaks between them. Disconnect and the thread is gone.

What’s Next?

We plan to update our official benchmarks. Multithreading changes the performance profile in ways that aren’t fully captured by single-connection numbers. We want to understand throughput at different concurrency levels, how memory scales per connection, and where the bottlenecks show up under real parallel load. Those numbers will tell us where to focus optimization work.

On the architecture side, the network subsystem has a lot of potential for optimization and better planning in scenarios with a large number of simultaneous connections. Automated testing remains an interesting topic. We already use tsan to run all tests (including v8 instrumenting), but more advanced tools like load or simulation testing may appear in the future.

Try It

Pull the latest from main on GitHub and run your workloads against it. The quickstart guide will get you connected in a few minutes.

If something breaks, open an issue and come and chat to us on Discord. We respond to every message.


Nikolay Govorov

Nikolay Govorov

Software Engineer

Nikolay is a software engineer at Lightpanda, where he works on the core browser engine.