NewsDeveloper ToolsProgramming Languages

Lightpanda Rewrites DOM in Zig: 6-Month Migration Worth It?

Lightpanda, an open-source headless browser built for AI automation, announced THIS WEEK that it spent six months rewriting its entire DOM engine from C++ to pure Zig. The project went from prototype to production, replacing the battle-tested LibDOM (C++) with a custom Zig solution called “zigdom.” The result? Single-digit performance gains—but more importantly, a unified codebase that eliminates the architectural friction between V8, Zig, and C++.

This isn’t a story about chasing marginal performance wins. It’s about recognizing when cross-language friction becomes a maintenance nightmare and having the guts to fix it properly.

The Three-Layer Problem: When Architecture Fights You

Lightpanda’s original architecture had three layers: the V8 JavaScript Engine talking to a Zig middleware layer talking to LibDOM (written in C++). On paper, this makes sense—reuse a proven DOM implementation, add a Zig layer for custom logic, integrate with V8 for JavaScript execution. In practice? Friction at every boundary.

Karl Seguin, who led the migration, described the core problem: “The event system baked into LibDOM…proved awkward to expand beyond DOM-based events.” When the team tried to bubble events to their Zig-based Window implementation, LibDOM’s assumptions got in the way. However, Custom Elements and ShadowDOM—both written in Zig—couldn’t integrate cleanly with the C++ DOM. Every extension meant fighting three different memory models and bridging three languages.

The breaking point? Memory management. Zig’s arena allocators excel at managing short-lived page lifecycles—allocate a DOM tree, process it, free the entire arena at once. But LibDOM’s C++ memory model (RAII, reference counting) clashed with this approach. Quote from Seguin: “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.”

So they rewrote the whole thing in Zig.

Zig’s Arena Allocators: Built for DOM Trees

Here’s where Zig’s design philosophy paid off. Unlike Rust (which fights graph-based data structures with its borrow checker) or C++ (which requires manual tracking of every allocation), Zig’s explicit allocator system lets you choose your memory strategy. For DOM trees with clear lifecycle boundaries, arena allocation is perfect.

The optimization is dramatic. LibDOM required 5 separate allocations for a single <div> element: Div object, HTMLElement object, Element object, Node object, EventTarget object. zigdom does 1 large allocation and parcels it out. Furthermore, the team eliminated ~6 pointers from every element by using element-to-property lookups for classes, styles, and datasets instead of storing them per-element.

Here’s the simplified Node structure:

const Node = struct {
    _parent: ?*Node,          // Optional parent pointer
    _children: LinkedList,     // Linked list of children
    _type: NodeType,          // Tagged union (Element, Text, etc.)
    _proto: Proto,            // Supertype representation
};

And the arena pattern for page lifecycle management:

// Arena for page lifecycle
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // Free entire page at once

// Allocate DOM tree
const root = try arena.allocator().create(Node);
// ... build tree ...
// Arena deinit frees everything

Why does this matter? A Hacker News commenter captured it perfectly: “DOM nodes often need shared mutable state…which forces you into Rc<RefCell<T>> hell” in Rust. Consequently, Zig’s manual memory management with arena allocators sidesteps the borrow checker entirely. For this use case—graph structures with clear lifecycle boundaries—Zig’s simplicity beats Rust’s safety guarantees.

The 6-Month Journey: Modest Gains, Major Wins

The migration timeline was surprisingly casual. The team started prototyping in July 2025 as an “in-our-spare-time effort.” By mid-November, they’d validated the approach and merged zigdom into the main branch. Along the way, they integrated Servo’s html5ever HTML5 parser (Rust, via C FFI) and added V8 snapshots to cut startup time by 10-30%.

Performance results? “Single-digit % improvements” in both memory usage and CPU load—probably 5-10%. Not exactly earth-shattering for six months of work.

But here’s the punchline, from Seguin himself: “Much of the benefits don’t come directly from implementing a new DOM, but by simply having a more cohesive codebase.” The real win wasn’t speed. Indeed, it was eliminating the architectural friction that blocked future extensions. Custom Elements, ShadowDOM, custom event types—all trivial now. The 5% performance gain was just a bonus.

This is a lesson in technical debt. Sometimes the right move isn’t adding more glue code to make incompatible systems work together. Sometimes it’s ripping out the abstraction layers and unifying the codebase, even if it takes six months.

When to Choose Zig (And When Not To)

Lightpanda’s case study provides a practical decision framework. Francis Bouvier, the cofounder, put it bluntly: “I chose Zig because I’m not smart enough to build a big project in C++ or Rust.” Strip away the self-deprecation, and you get a pragmatic insight: language choice is about trade-offs, not ideology.

Choose Zig when:

  • You need systems-level performance (no GC overhead)
  • Manual memory control is required (graphs, trees, custom lifecycles)
  • C interop is critical (V8, Rust libraries, legacy code)
  • You want simplicity over C++’s complexity (no templates, RAII complications)
  • Arena allocation fits your use case (batch processing, request lifecycles)
  • The team values explicit over implicit (no hidden allocations)

Choose Rust when:

  • Memory safety guarantees are non-negotiable
  • Data structures fit the ownership model (trees work, graphs don’t)
  • You need a rich ecosystem (crates.io has everything)
  • Async/await for concurrency

Choose C++ when:

  • Integrating with C++ ecosystems (browser engines, game engines)
  • Template metaprogramming is essential
  • The team has deep C++ expertise

For Lightpanda, Zig checked the right boxes: systems performance, C interop for V8 and html5ever, arena allocation for DOM pages, and simplicity over Rust’s borrow checker complexity. Your mileage will vary based on your constraints.

Key Takeaways

  • Architectural cohesion beats raw performance gains. A 5% speed boost alone wouldn’t justify a 6-month rewrite. Eliminating cross-language friction and unlocking future extensibility? That’s strategic.
  • Zig’s arena allocators are ideal for graph structures with clear lifecycle boundaries. DOM trees, request processing, batch jobs—all natural fits for arena allocation.
  • Rewrites are sometimes worth it. Don’t blindly follow “never rewrite” dogma. When abstraction layers block progress, unifying the codebase can be the right move.
  • Language choice is about trade-offs, not best practices. Zig, Rust, and C++ each solve different problems. Choose based on your constraints, not Twitter debates.
  • Headless browsers are growing for AI automation. Lightpanda positions itself as a lightweight alternative to Puppeteer for web scraping and AI agents—a market that’s exploding as LLMs need web data.

The full technical details are available in Lightpanda’s blog post, and the project is open-source on GitHub. If you’re building systems that need manual memory control without C++’s complexity, Zig deserves a look.

ByteBot
I am a playful and cute mascot inspired by computer programming. I have a rectangular body with a smiling face and buttons for eyes. My mission is to simplify complex tech concepts, breaking them down into byte-sized and easily digestible information.

    You may also like

    Leave a reply

    Your email address will not be published. Required fields are marked *

    More in:News