Lightpanda: A Headless Browser Without a Renderer
Why Francis Bouvier and Pierre Tachoire wrote a new browser in Zig instead of forking Chromium, and what the 123MB vs 2GB number really measures.
Timeline
- 2023-02-07 Repository created
Francis Bouvier and Pierre Tachoire create lightpanda-io/browser on GitHub through Selecy SAS. The first commits land that month.
- 2024-2025 Engine integrations land
v8 via the zig-v8-fork dependency, libcurl with a Zig-side allocator wrapper, and html5ever as a Rust static library linked through extern "C" callbacks. The Web API surface under src/browser/webapi grows to about sixty files.
- 2026-05-04 Pinned commit for these tours
Commit 9c669974 is the snapshot the tours reference. Nearly 30,000 stars at this point. Zig 0.15.2 required. CDP server, fetch mode, MCP server, and the file-based skill at lightpanda-io/agent-skill are all in place.
The Problem With Headless Chrome
Headless Chrome works. The catch is what it costs. Chromium is a desktop browser with the rendering disabled, not a browser built for headless. It still ships a compositor, a layout engine, GPU process plumbing, IPC for sandboxing, and a lot more. The README's Why Lightpanda? section is direct: "Heavy on RAM and CPU. Hard to package, deploy, and maintain at scale. Many features are not necessary in headless mode." Running 100 instances of Chrome to crawl 100 pages is feasible; running 10,000 to back an agent platform is not.
The numbers on the README come from a benchmark in lightpanda-io/demo that crawls 933 real web pages on an AWS m5.large. Lightpanda finishes 100 pages in 5 seconds with a 123MB peak; headless Chrome takes 46 seconds with a 2GB peak. Those are two roughly order-of-magnitude differences, and they come from the same place: Lightpanda chose not to build the parts Chrome needs and a crawler does not.
What Got Cut
Lightpanda has no compositor, no layout engine, no GPU process, and no rendering at all. src/browser/webapi/ has Document, Element, Event, MutationObserver, IntersectionObserver, and the rest of the DOM and event surface, but nothing that paints pixels. The Frame and Page types in src/browser/ hold a parsed tree and a JS context; they do not hold a render tree.
The event loop is the same idea. src/browser/Runner.zig is a polling loop that drives libcurl's multi handle, processes queued navigations, runs pending scripts, and pumps v8's microtask and macrotask queues. There is no requestAnimationFrame contract to honor against a real screen, no vsync, no compositor frame timing. Once per second during a long wait, the runner sends v8 a moderate memory pressure hint so external references get released; that is the closest the loop comes to anything graphical.
What Got Built Instead
Three foreign engines link into one Zig binary. build.zig.zon declares the dependencies: v8 from lightpanda-io/zig-v8-fork, BoringSSL via Syndica's zig fork, curl 8.18.0, brotli, zlib, nghttp2, sqlite3, libidn2. build.zig calls linkV8, linkCurl, and linkHtml5Ever at module setup time. The html5ever step shells out to cargo build on src/html5ever/Cargo.toml and links the resulting Rust static library; the v8 step builds a C++ wrapper from the zig-v8-fork dependency.
The interfaces between them are deliberate. On the network side, src/network/Network.zig includes a ZigToCurlAllocator that wraps curl's malloc/calloc/realloc/free hooks so every byte curl allocates flows through a Zig std.mem.Allocator. The Rust parser entry point in src/html5ever/lib.rs takes a sixteen-callback table on every parse call so the tree-building operations land back in Zig code that owns the DOM. On the JS side, src/browser/js/Env.zig creates the v8 isolate, loads a snapshot blob, and registers function templates that bind every Web API class to v8 via the bridge in src/browser/js/bridge.zig. The pattern is consistent: the foreign engine does its job, but the data structure and the lifetime are owned by Zig.
Why Zig
Zig was chosen for two reasons. The first is explicit memory control. The browser instantiates a fresh Allocator per page (src/browser/Page.zig calls session.arena_pool.acquire(.large, "Page.frame_arena")), and the per-page arena is reset or released wholesale on navigation. There is no per-DOM-node refcount, no garbage collector outside v8 itself. Memory the page allocates lives until the page dies; then it goes back to the pool in one operation. src/ArenaPool.zig implements the pool with four bucket sizes and a free list per bucket.
The second is errors as values. src/network/http.zig Headers init is the canonical example: each curl_slist_append can return null, each null returns error.OutOfMemory, and errdefer libcurl.curl_slist_free_all(header_list) ensures partial state is cleaned up if a later append fails. The pattern repeats throughout. src/App.zig init has six errdefers in sequence; src/browser/js/Env.zig init has half a dozen more around the v8 isolate and array buffer allocator. There are no exceptions to unwind through C++ and Rust frames, and no place in the codebase where partial initialization leaks.
What This Means for Reading the Code
Lightpanda is small enough to read end to end. The Architecture tour walks build.zig, src/lightpanda.zig, the libcurl HTTP layer, the html5ever Rust-to-Zig FFI, the Zig parser callbacks, the v8 Env setup, and the Runner loop. The Memory tour covers the allocator strategy: the GPA-versus-c_allocator split in main, the per-page arena pool, the libcurl allocator boundary, the errdefer pattern in App init, and the memory pressure hints in Runner. Together they show what a browser looks like when nothing is incidental and the rendering layer is absent on purpose.
Sources
Ready to explore the code?
Start the Lightpanda tour