A multi-tenant headless browser for AI agents and automation at scale.
Built on Lightpanda. Not a Chromium fork. Written in Zig.
The benchmark that matters for multi-tenancy: N simultaneous CDP clients, each crawling pages from the Amiibo demo site served locally via Cloudflare tunnel. Run on Mac mini (arm64) with Docker.
| Clients | ZenPanda Success | Lightpanda Success | ZenPanda p/s | Lightpanda p/s | ZenPanda Mem | Lightpanda Mem |
|---|---|---|---|---|---|---|
| 1 | 100% | 100% | 0.61 | 0.60 | 4.1 MiB | 5.2 MiB |
| 5 | 100% | 100% | 2.75 | 2.95 | 4.7 MiB | 6.4 MiB |
| 10 | 100% | 100% | 5.42 | 4.90 | 4.3 MiB | 15.1 MiB |
| 20 | 100% | 100% | 10.42 | 10.33 | 13.9 MiB | 13.8 MiB |
| 50 | 100% | 86.8% | 22.62 | 19.49 | 4.9 MiB | 15.2 MiB |
| 500 | 77.6% | 39.2% | 16.67 | 15.70 | 11.5 MiB | 22.2 MiB |
ZenPanda maintains perfect reliability up to 50 concurrent clients with all 500 connecting successfully even at the highest load. Lightpanda starts dropping connections at 50+ clients.
Key differences:
- ZenPanda gives each client its own V8 isolate — true parallel JS execution, no mutex contention, perfect reliability under moderate load.
- Lightpanda shares one V8 isolate across all connections — lower per-client overhead but serialized JS execution and connection drops under load.
- At 50 clients, ZenPanda achieves 100% success vs Lightpanda's 86.8%, with 16% higher throughput.
- At 500 clients, ZenPanda connects 500/500 clients vs Lightpanda's 345/500, with 77.6% success vs 39.2% and higher throughput.
The tradeoff: ZenPanda trades memory for reliability. For multi-tenant workloads serving real users, 100% availability matters more than raw per-page throughput.
Run
make benchto reproduce. Seebench/BENCHMARKS.mdfor details.
Requesting 933 real web pages over the network on a AWS EC2 m5.large instance. See benchmark details.
| Metric | ZenPanda | Headless Chrome | Difference |
|---|---|---|---|
| Memory (peak, 100 pages) | 123MB | 2GB | ~16x less |
| Execution time (100 pages) | 5s | 46s | ~9x faster |
Package Managers
Latest nightly from Homebrew:
brew install lightpanda-io/browser/lightpandaLatest nightly from Arch Linux User Repository:
yay -S lightpanda-nightly-biDownload from the nightly builds
You can download the last binary from the nightly builds for Linux and MacOS for both x86_64 and aarch64.
For Linux
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && \
chmod a+x ./lightpandaVerify the binary before running anything:
./lightpanda versionLinux aarch64 is also available
Note: The Linux release binaries are linked against glibc. On musl-based distros (Alpine, etc.) the binary fails with
cannot execute: required file not foundbecause the glibc dynamic linker is missing. Use a glibc-based base image (e.g.,FROM debian:bookworm-slimorFROM ubuntu:24.04) or build from sources.
For MacOS
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \
chmod a+x ./lightpandaMacOS x86_64 is also available
For Windows + WSL2
ZenPanda has no native Windows binary. Install it inside WSL following the Linux steps above.
WSL not installed? Run wsl --install from an administrator shell, restart, then open wsl.
See Microsoft's WSL install guide for details.
Your automation client (Puppeteer, Playwright, etc.) can run either inside WSL or on the Windows host. WSL forwards localhost:9222 automatically.
Install from Docker
ZenPanda provides multi-arch Docker images on Docker Hub for Linux arm64 and amd64.
The following command fetches the Docker image and starts a new container exposing ZenPanda's CDP server on port 9222.
docker run -d --name zenpanda -p 127.0.0.1:9222:9222 ronxldwilson/zenpanda:latestBuilding Multi-Arch Docker Images
The recommended way to build Docker images is to cross-compile natively on macOS using build-linux.sh, then package the binary with Dockerfile.package. This avoids Docker's memory constraints during the heavy Zig/LLVM compilation.
# 1. Build the amd64 binary (cross-compiles from macOS)
./build-linux.sh x86_64
# 2. Package and push the amd64 image
docker buildx build -f Dockerfile.package --platform linux/amd64 \
-t ronxldwilson/zenpanda:amd64 --push .
# 3. Build the arm64 binary
./build-linux.sh aarch64
# 4. Package and push the arm64 image
docker buildx build -f Dockerfile.package --platform linux/arm64 \
-t ronxldwilson/zenpanda:arm64 --push .
# 5. Create the combined multi-arch manifest
docker buildx imagetools create -t ronxldwilson/zenpanda:latest \
ronxldwilson/zenpanda:arm64 ronxldwilson/zenpanda:amd64build-linux.sh handles everything: installs Zig locally, downloads the correct V8 prebuilt, cross-compiles html5ever via cargo-zigbuild, and produces a Linux binary in dist/. Artifacts are cached between runs.
Note: Building entirely inside Docker (
Dockerfile) is also supported but requires Docker Desktop to have at least 8 GB of RAM allocated (Settings > Resources > Memory). The native build approach above has no such constraint.
./lightpanda fetch --obey-robots --dump html --log-format pretty --log-level info https://demo-browser.lightpanda.io/campfire-commerce/You can use --dump markdown to convert directly into markdown.
--wait-until, --wait-ms, --wait-selector and --wait-script are
available to adjust waiting time before dump.
./lightpanda serve --obey-robots --log-format pretty --log-level info --host 127.0.0.1 --port 9222Once the CDP server started, you can run a Puppeteer script by configuring the
browserWSEndpoint.
Example Puppeteer script
import puppeteer from 'puppeteer-core';
// use browserWSEndpoint to pass the ZenPanda's CDP server address.
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://127.0.0.1:9222",
});
// The rest of your script remains the same.
const context = await browser.createBrowserContext();
const frame = await context.newPage();
// Dump all the links from the frame.
await frame.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
const links = await frame.evaluate(() => {
return Array.from(document.querySelectorAll('a')).map(row => {
return row.getAttribute('href');
});
});
console.log(links);
await frame.close();
await context.close();
await browser.disconnect();The MCP server communicates via MCP JSON-RPC 2.0 over stdio.
Add to your MCP configuration:
{
"mcpServers": {
"lightpanda": {
"command": "/path/to/lightpanda",
"args": ["mcp"]
}
}
}A skill is available in lightpanda-io/agent-skill.
By default, ZenPanda collects and sends usage telemetry (via upstream Lightpanda infrastructure). This can be disabled by setting an environment variable LIGHTPANDA_DISABLE_TELEMETRY=true. See the upstream privacy policy.
ZenPanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work. You may still encounter errors or crashes. Please open an issue with specifics if so.
Here are the key features we have implemented:
- CORS #2015
- HTTP loader (Libcurl)
- HTML parser (html5ever)
- DOM tree
- Javascript support (v8)
- DOM APIs
- Ajax
- XHR API
- Fetch API
- DOM dump
- CDP/websockets server
- Click
- Input form
- Cookies
- Custom HTTP headers
- Proxy support
- Network interception
- Respect
robots.txtwith option--obey-robots
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
ZenPanda supports multi-tenant workloads within a single process, packing many concurrent browser sessions into minimal memory.
Each CDP WebSocket connection can host N isolated BrowserContexts, each with its own:
- Session and Page (navigation, DOM, JS execution)
- Inspector session with unique context group ID (DevTools isolation)
- Arena allocators (frame, notification, browser-context scoped)
- Target ID and Session ID (CDP protocol routing)
BrowserContexts share a single V8 Isolate (~5 MiB) and HTTP connection pool per connection, keeping per-context overhead minimal.
Key internals:
CDPstores aStringHashMap(*BrowserContext)and asession_to_contextmap for O(1) dispatch routingBrowsersupports a pool of concurrentSessionobjectsInspectormanages multiple sessions with per-context group IDsTarget.*commands resolve contexts bybrowserContextId,targetId, orsessionId
By default, each browser instance gets its own V8 Isolate for true parallel JavaScript execution — no mutex contention between clients. This enables linear throughput scaling under concurrent load.
Optionally, multiple CDP connections can share one V8 Isolate (shared_env: true) — one 5 MiB cost for all connections instead of N × 5 MiB. When sharing, a std.Thread.Mutex on App serializes V8 access at tick boundaries since V8 Isolates are not thread-safe. The mutex is automatically skipped when browsers own their own isolate.
Browser.envis a borrowed*js.Envpointer (owned or shared viaApp.getOrCreateSharedEnv())CDP.tick()only locksv8_mutexwhenbrowser.owns_env == false- Context limit increased to 256 per isolate
Pool management, shared caches, memory pressure eviction, and health monitoring.
| Component | File | Purpose |
|---|---|---|
| BrowserPool | src/BrowserPool.zig |
Thread-safe pool with min_warm/max_total config, acquire/release lifecycle, idle eviction |
| SharedCache | src/SharedCache.zig |
LRU cache keyed by URL for sharing parsed resources across browsers |
| HealthMonitor | src/HealthMonitor.zig |
Background thread for periodic memory pressure checks and pool stats |
Features:
- Pre-warm browser instances at startup (
initBrowserPoolwithmin_warm) - V8
lowMemoryNotificationon every pool release - Configurable heap limit with automatic idle eviction under pressure
/json/healthHTTP endpoint for liveness checksSharedCachewith hit/miss stats and configurable memory cap
ZenPanda is a fork of lightpanda-io/browser. Upstream ships bug fixes, new Web APIs, and performance improvements that we want. This section documents how to stay in sync safely.
Add the upstream remote if you haven't already:
git remote add upstream https://github.com/lightpanda-io/browser.git# 1. Fetch upstream (no tags to keep refs clean)
git fetch upstream --no-tags
# 2. Check what's new
git log --oneline $(git merge-base HEAD upstream/main)..upstream/main
# 3. Preview file-level overlap
git diff --name-only $(git merge-base HEAD upstream/main) upstream/main # upstream changes
git diff --name-only $(git merge-base HEAD upstream/main) HEAD # our changes
# Files in BOTH lists are conflict candidates
# 4. Trial merge (dry run)
git merge --no-commit --no-ff upstream/main
git diff --cached --stat # inspect what would change
git merge --abort # back out
# 5. Real merge (when satisfied)
git merge upstream/main -m "Merge upstream lightpanda-io/browser"
git push origin mainImportant: always use git merge, never git rebase when syncing upstream. Rebase rewrites commit SHAs and breaks GitHub's ahead/behind tracking for forks.
A clean merge (no git conflicts) does not mean our code still works. Upstream may change internal APIs that our multi-tenancy modules depend on. The critical dependency surface is:
| Our Module | Upstream APIs We Depend On |
|---|---|
| BrowserPool | Browser.init(app, opts, cdp), Browser.deinit(), Browser.reset(), Browser.http_client.init/deinit(), Env.isolate.getHeapStatistics(), Env.isolate.lowMemoryNotification(), Env.isolate.memoryPressureNotification() |
| SharedCache | lp.log only (self-contained) |
| HealthMonitor | App.browser_pool, App.shared_cache, App.shutdown(), BrowserPool.stats(), BrowserPool.checkMemoryPressure(), SharedCache.stats() |
| App (multi-tenancy) | js.Env.init(app, opts), Network.init/deinit(), Network.shutdown.load(.acquire), Platform.init/deinit(), Snapshot.load/deinit() |
| CDP (our changes) | Browser.env.terminate(), Browser.env.inspector, Session.createPage/removePage/hasPage/currentFrame, Frame._frame_id, Frame.navigate(), Notification event registration, Network.registerCdp/unregisterCdp |
| Browser (our changes) | js.Env.init(), App.getOrCreateSharedEnv(), HttpClient.init(allocator, network, cdp?), Session.init() |
After every upstream merge, run the integration tests to verify our multi-tenancy layer still compiles and works:
# Run all tests (includes upstream + our integration tests)
make test
# Filter to just our multi-tenancy tests
make test F="BrowserPool"
make test F="SharedCache"
make test F="HealthMonitor"
make test F="App:"What the tests cover:
- SharedCache (
src/SharedCache.zig) — put/get, hit/miss stats, eviction under max_bytes, oversized entry rejection, clear, duplicate key handling - BrowserPool (
src/BrowserPool.zig) — init/deinit, acquire/release lifecycle, pool exhaustion (error.PoolExhausted), warmUp pre-creation, idle reuse, stats correctness, evictIdle respecting min_warm, checkMemoryPressure, V8 isolate API surface (getHeapStatistics,lowMemoryNotification,memoryPressureNotification) - HealthMonitor (
src/HealthMonitor.zig) — status with no subsystems, status reflecting pool state, status reflecting cache state, start/stop thread lifecycle, double-start idempotency - App orchestration (
src/App.zig) — initBrowserPool/deinitBrowserPool, initSharedCache/deinitSharedCache, startHealthMonitor/stopHealthMonitor, idempotent deinit of optional subsystems, shutdown network state, getOrCreateSharedEnv pointer stability, full multi-tenancy lifecycle (pool + cache + monitor together)
If upstream renames a field, changes a function signature, or removes an API, these tests will fail at compile time — you'll know immediately what broke and where.
After the compile tests pass, run the multi-client benchmark to catch runtime regressions that unit tests won't find (e.g., deferred operations that add per-tick latency under concurrent load):
# Quick smoke test (1, 10, 50 clients) — takes ~5 minutes
make bench-quick
# Full sweep including 200 and 500 clients — takes ~15 minutes
make benchSee bench/BENCHMARKS.md for detailed instructions, prerequisites, what to look for, and documentation of past regressions.
When reviewing upstream commits, pay extra attention to changes in:
API surface (compile-time breakage):
src/browser/Browser.zig— init/deinit/reset signaturessrc/browser/js/Env.zig— V8 isolate lifecycle, InitOptssrc/browser/HttpClient.zig— init signature, disconnect handlingsrc/cdp/CDP.zig— session management, BrowserContext lifecyclesrc/network/Network.zig— CDP link registration, shutdown flowsrc/browser/Session.zig— page management APIs
Performance hot path (runtime regression — needs make bench):
src/browser/HttpClient.zig— tick loop,perform()poll timeouts, NextTick queuesrc/network/layer/CacheLayer.zig— synchronous vs deferred cache servingsrc/browser/ScriptManager.zig— script execution timing (sync vs deferred)src/browser/Session.zig— teardown cleanup,memoryPressureNotificationsrc/browser/Runner.zig— tick timeout, wait loop cadence
ZenPanda is written with Zig 0.15.2. You have to
install it with the right version in order to build the project.
ZenPanda also depends on v8, Libcurl and html5ever.
To be able to build the v8 engine, you have to install some libs:
For Debian/Ubuntu based Linux:
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl git
You also need to install Rust.
For systems with Nix, you can use the devShell:
nix develop
For MacOS, you need cmake and Rust.
brew install cmake
You can build the entire browser with make build or make build-dev for debug
env.
But you can directly use the zig command: zig build run.
Lighpanda uses v8 snapshot. By default, it is created on startup but you can embed it by using the following commands:
Generate the snapshot.
zig build snapshot_creator -- src/snapshot.bin
Build using the snapshot binary.
zig build -Dsnapshot_path=../../snapshot.bin
See #1279 for more details.
You can test ZenPanda by running make test.
make test # Run all tests
make test F="server" # Filter by substring
TEST_FILTER="WebApi: #selector_all" make test # Filter main + subtest (separator: #)
TEST_VERBOSE=true make test
TEST_FAIL_FIRST=true make test
METRICS=true make test # Capture allocation/duration metrics as JSONTo run end to end tests, you need to clone the demo
repository into ../demo dir.
You have to install the demo's node requirements
You also need to install Go > v1.24.
make end2end
ZenPanda is tested against the standardized Web Platform Tests.
We use a fork including a custom
testharnessreport.js.
For reference, you can easily execute a WPT test case with your browser via wpt.live.
To run the test, you must clone the repository, configure the custom hosts and generate the
MANIFEST.json file.
Clone the repository with the fork branch.
git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
Enter into the wpt/ dir.
Install custom domains in your /etc/hosts
./wpt make-hosts-file | sudo tee -a /etc/hosts
Generate MANIFEST.json
./wpt manifest
Use the WPT's setup guide for details.
An external Go runner is provided by
github.com/lightpanda-io/demo/
repository, located into wptrunner/ dir.
You need to clone the project first.
First start the WPT's HTTP server from your wpt/ clone dir.
./wpt serve
Run a ZenPanda browser
zig build run -- --insecure-disable-tls-host-verification
Then you can start the wptrunner from the demo's clone dir:
cd wptrunner && go run .
Or one specific test:
cd wptrunner && go run . Node-childNodes.html
wptrunner command accepts --summary and --json options modifying output.
Also --concurrency define the concurrency limit.
releaseFast mode to make tests faster.
zig build -Doptimize=ReleaseFast run
See CONTRIBUTING.md for guidelines. You must sign our CLA during the pull request process.
Simple HTTP requests used to be enough for web automation. That's no longer the case. Javascript now drives most of the web:
- Ajax, Single Page Apps, infinite loading, instant search
- JS frameworks: React, Vue, Angular, and others
Running a full desktop browser on a server works, but it does not scale well. Chrome at hundreds or thousands of instances is expensive:
- Heavy on RAM and CPU
- Hard to package, deploy, and maintain at scale
- Many features are not necessary in headless made
Supporting Javascript with real performance meant building from scratch rather than forking Chromium:
- Not based on Chromium, Blink, or WebKit
- Written in Zig, a low-level language with explicit memory control
- No graphical rendering engine
