Migrating our DOM to Zig

Karl Seguin

Karl Seguin

Software Engineer

Migrating our DOM to Zig

TL;DR

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.

Why We Replaced LibDOM

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.

However, as we worked to increase compatibility with websites in the wild, we felt ever-increasing friction between our three layers. One example is the event system baked into LibDOM. This proved awkward to expand beyond DOM-based events (e.g. input events) or even just bubbling DOM events to our Zig-based Window implementation. Another larger challenge, was integrating support for Custom Elements and ShadowDOM, written in Zig, with LibDOM. Finally, there was some concern about the lack of cohesion with respect to things like memory management and how that would impact potential future changes, like better multi-threading support.

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.

zigdom

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, // ... };

Since a modern website can have tens of thousands of nodes and thousands of elements, we obviously care about the size of our EventTarget, Node and Element. That’s why every type in our union is a pointer. That means that when we create a div, we need to allocate the Div, HTMLElement, Element, Node and EventTarget. But rather than doing five separate allocations, we do 1 large allocation for the total size and parcel it out.

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.

The real win is having a more cohesive design for events, custom elements and ShadowDOM and a simpler foundation for future enhancements. That said, performance, both in terms of memory usage and CPU load are slightly improved (both single digit % improvements).

html5ever

We saw benefits to writing our own DOM implementation, but not our own HTML parser. For that, we turned to servo’s html5ever written in Rust. I almost forgot to mention it in this post because the experience was so painless and worked from the get-go that I haven’t had to think about it for a while. You setup html5ever with a bunch of callbacks (for creating a node, attaching a node to a parent, creating text, etc.), feed it your HTML, and away you go. My Rust is very bad, but writing a C binding for it was manageable.

Bonus - V8 Snapshot

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.

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.

AI Coding Agent

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.

That said, I do think this is first and foremost a code-review exercise, and the Claude CLI is lacking in that respect. When you’re trying to understand and think through a change, you need a comfortable interface that lets you navigate and access whatever context you’re missing. Anything more than a few lines becomes challenging to review, especially as it’s presented for you to accept one piece at a time.

In the end, it’s a tool that supplements my own abilities.

What’s Next

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.


Karl Seguin

Karl Seguin

Software Engineer

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.