At a high level, the Lightpanda codebase can be described as a Zig layer sitting between V8 and LibDOM. When JavaScript is executed, like document.getElementById(‘spice’), V8 calls into the Zig layer which then forwards the request to the underlying LibDOM document object and then forwards the result back to V8. By using LibDOM, we gained a robust and fairly comprehensive DOM implementation with minimal effort.
If we were to restart the integration from scratch, knowing what we know now, we’d probably be able to avoid most of the friction we’re currently seeing. While we do modify LibDOM as needed, one approach would be to integrate V8 and LibDOM directly, applying fixes and additions directly to LibDOM. But as we wrote before, we’re fans of Zig so the discussions and prototypes we built always leaned towards replacing LibDOM with a custom Zig implementation.
Work on a prototype for having a Zig-based DOM started roughly six months ago. This was a casual in-our-spare-time effort. In the spirit of experimentation, this prototype also replaced V8 with QuickJS-NG . By mid-November, we felt the prototype had tackled enough unknowns to start integrating it into Lightpanda (with V8). Thankfully, porting features was relatively simple; commits to the main branch could usually be ported to the zigdom branch.
The design is straightforward. A Node has a linked list of children and an optional _parent: ?*Node. Furthermore, a Node has a _type tagged union field to represent the type of node, and a _proto field to capture its supertype:
_type: Type, _proto: *EventTarget, const Type = union(enum) { cdata: *CData, element: *Element, document: *Document, // … };
Another area where we’ve been able to optimize for our use-case is to lazily parse/load certain properties. While a website might have thousands of elements, most JavaScript will only access the classes, styles, relLists, dataset, etc, of a few elements. Rather than having these stored on each element, even as empty lazily loaded containers, they’re attached to a page in an element -> property lookup. While this adds lookup overhead, it removes ~6 pointers from every element.
When in debug mode, we generated the snapshot on startup. In release mode, the snapshot is generated at compile-time and embedded into the binary, reducing startup time and memory. The overall impact depends on the relative cost of setting up the environment vs processing the page. Complex websites that load hundreds of external scripts probably won’t benefit. But incremental improvements hopefully add up and, if nothing else, help balance the performance cost of new features and complexity.
This was the first large feature that I developed with the aid of an AI coding agent – specifically Claude. The experience was positive, but not flawless. I’ve personally always liked participating in code reviews / PRs. I can spend hours every day reviewing PRs, so working with Claude is kind of fun for me. If reading code isn’t something you consider fun, it could be a frustrating experience.
I was almost always impressed with the quality of code written and “understanding” that Claude exhibited. I’m only guessing here, but I have to imagine that building a DOM, something which has a very explicit specification, tons of documentation and many implementations, was an ideal task for a coding agent.
Implementing our own DOM from scratch should make it easier for us to add new features and enhancements. Something we’ve already seen with better custom element and ShadowRoot support. Much of the benefits don’t come directly from implementing a new DOM, but by simply having a more cohesive codebase. For us, expanding our usage of Zig made the most sense.
zigdom is now merged into Lightpanda’s main branch. If you want to see how we structured the Node, Element, and event system in Zig, check out the source code on GitHub .
We replaced LibDOM with our own Zig-based DOM implementation. The original design created friction between V8, our Zig layer, and LibDOM, especially around events, Custom Elements, and ShadowDOM. After six months of spare-time prototyping, we built zigdom: a leaner, more cohesive DOM that gives us full control over memory, events, and future enhancements. We also swapped in html5ever for parsing and added V8 snapshots to cut startup time. There are single-digit % performance gains, but the real win is a unified codebase that’s easier to extend.
Some of the porting was tedious. For a change of pace, I took time to see how we could leverage V8 snapshots. As a short summary, whenever you create a V8 environment to execute code, you have to do a lot of setup. Every type (hundreds) with all the functions and properties need to be registered with the V8 environment. For a simple page, this can represent anywhere from 10-30% of the total time. V8 snapshots let you setup a pseudo-environment upfront, extract a Snapshot (a binary blob), and use that blob to bootstrap and speedup future environments.
On this pageTL;DRWhy We Replaced LibDOMzigdomhtml5everBonus – V8 SnapshotAI Coding AgentWhat’s Next
zig architecture dom performance v8 Thursday, January 8, 2026
In the end, it’s a tool that supplements my own abilities.
Karl is a software engineer, creator of popular open-source Zig libraries like http.zig or websocket.zig. Karl has been writing about programming for years on his blog openmymind.net and is the author of Learning Zig, a series of articles to help other developers pick up the language. At Lightpanda, he works on building the core browser engine.



Leave a Reply
You must be logged in to post a comment.