Why We Built Lightpanda in Zig

Because We're Not Smart Enough for C++ or Rust

Francis Bouvier

Francis Bouvier

Cofounder & CEO

Why We Built Lightpanda in Zig

TL;DR

To be honest, when I began working on Lightpanda, I chose Zig because I’m not smart enough to build a big project in C++ or Rust.

I like simple languages. I like Zig for the same reasons I like Go, C, and the KISS principle. Not just because I believe in this philosophy, but because I’m not capable of handling complicated abstractions at scale.

Before Lightpanda, I was doing a lot of Go. But building a web browser from scratch requires a low-level systems programming language to ensure great performance, so Go wasn’t an option. And for a project like this, I wanted more safety and modern tooling than C.

Why We Built Lightpanda in Zig

Our requirements were performance, simplicity, and modern tooling. Zig seemed like the perfect balance: simpler than C++ and Rust, top-tier performance, and better tooling and safety than C.

As we built the first iterations of the browser and dug deeper into the language, we came to appreciate features where Zig particularly shines: comptime metaprogramming, explicit memory allocators, and best-in-class C interoperability. Not to mention the ongoing work on compilation times.

Of course it’s a big bet. Zig is a relatively new language with a small ecosystem. It’s pre-1.0 with regular breaking changes. But we’re very bullish on this language, and we’re not the only ones: Ghostty, Bun, TigerBeetle, and ZML are all building with Zig. And with Anthropic’s recent acquisition of Bun, big tech is taking notice.

Here’s what we’ve learned.

What Lightpanda Needs from a Language

Before diving into specifics, let’s talk about what building a browser for web automation requires.

First, we needed a JavaScript engine. Without one, a browser only sees static HTML: no client-side rendering and no dynamic content. We chose V8, Chrome’s JavaScript engine, because it’s state of the art, widely used (Node.js, Deno), and relatively easy to embed.

V8 is written in C++, and doesn’t have a C API, which means any language integrating with it must handle C++ boundaries. Zig doesn’t interoperate directly with C++, but it has first-class C interop, and C remains the lingua franca of systems programming. We use C headers generated primarily from rusty_v8, part of the Deno project, to bridge between V8’s C++ API and our Zig code.

Beyond integration, performance and memory control were essential. When you’re crawling thousands of pages or running automation at scale, every millisecond counts. We also needed precise control over short-lived allocations like DOM trees, JavaScript objects, and parsing buffers. Zig’s explicit allocator model fits that need perfectly.

Why Not C++?

C++ was the obvious option: it powers virtually every major browser engine. But here’s what gave us pause.

  • Four decades of features: C++ has accumulated enormous complexity over the years. There are multiple ways to do almost everything: template metaprogramming, multiple inheritance patterns, various initialization syntaxes. We wanted a language with one clear way to do things.
  • Memory management: Control comes with constant vigilance. Use-after-free bugs, memory leaks, and dangling pointers are real risks. Smart pointers help, but they add complexity and runtime overhead. Zig’s approach of passing allocators explicitly makes memory management clearer and enables patterns like arenas more naturally.
  • Build systems: Anyone who’s fought with CMake or dealt with header file dependencies knows this pain. For a small team trying to move quickly, we didn’t want to waste time debugging build configuration issues.

We’re not saying C++ is bad. It powers incredible software. But for a small team starting from scratch, we wanted something simpler.

Why not Rust?

Many people ask this next. It’s a fair challenge. Rust is a more mature language than Zig, offers memory safety guarantees, has excellent tooling, and a growing ecosystem.

Rust would have been a viable choice. But for Lightpanda’s specific needs (and honestly, for our team’s experience level) it introduced friction we didn’t want.

The Unsafe Rust Problem

When you need to do things the borrow checker doesn’t like, you end up writing unsafe Rust, which is surprisingly hard. Zack from Bun explores this in depth in his article When Zig is safer and faster than Rust.

Browser engines and garbage-collected runtimes are classic examples of code that fights the borrow checker. You’re constantly juggling different memory regions: per-page arenas, shared caches, temporary buffers, objects with complex interdependencies. These patterns don’t map cleanly to Rust’s ownership model. You end up either paying performance costs (using indices instead of pointers, unnecessary clones) or diving into unsafe code where raw pointer ergonomics are poor and Miri becomes your constant companion.

Zig takes a different approach. Rather than trying to enforce safety through the type system and then providing an escape hatch, Zig is designed for scenarios where you’re doing memory-unsafe things. It gives you tools to make that experience better: non-null pointers by default, the GeneralPurposeAllocator that catches use-after-free bugs in debug mode, and pointer types with good ergonomics.

Why Zig Works for Lightpanda

Zig sits in an interesting space. It’s a simple language that’s easy to learn, where everything is explicit: no hidden control flow, no hidden allocations.

Explicit Memory Management with Allocators

Zig makes you choose how memory is managed through allocators. Every allocation requires you to specify which allocator to use. This might sound tedious at first, but it gives you precise control.

Here’s what this looks like in practice, using an arena allocator:

const std = @import("std"); pub fn loadPage(_allocator: std.mem.Allocator, url: []const u8) !void { // Create an arena allocator for this page load var arena = std.heap.ArenaAllocator.init(_allocator); defer arena.deinit(); // Everything gets freed here const allocator = arena.allocator(); // All these allocations use the arena const dom_tree = try parseDom(allocator, url); const css_rules = try parseStyles(allocator, dom_tree); const js_context = try createJsContext(allocator); // Execute page, render, extract data... try executePage(js_context, dom_tree, css_rules); // Arena.deinit() frees everything at once, no leaks possible }

This pattern matches browser workloads perfectly. Each page load gets its own arena. When the page is done, we throw away the entire memory chunk. No tracking individual allocations, no reference counting overhead, no garbage collection pauses. (Though we’re learning that single pages can grow large in memory, so we’re also exploring mid-lifecycle cleanup strategies). And you can chain arenas, to create short-lived objects inside a page lifecycle.

Compile-Time Metaprogramming

Zig’s comptime feature lets you write code that runs during compilation. We use this extensively to reduce boilerplate when bridging Zig and JavaScript.

When integrating V8, you need to expose native types to JavaScript. In most languages, this requires glue code for each type. To generate this glue you need some code generation, usually through Macros (Rust, C, C++). Macros are a completely different language, which has a lot of downsides. Zig’s comptime lets us automate this:

const Point = struct { x: i32, y: i32, pub fn moveUp(self: *Point) void { self.y += 1; } pub fn moveDown(self: *Point) void { self.y -= 1; } }; // Our runtime can introspect this at compile time and generate bindings runtime.registerType(Point, "Point");

The registerType function uses comptime reflection to:

  • Find all public methods on Point
  • Generate JavaScript wrapper functions
  • Create property getters/setters for x and y
  • Handle type conversions automatically

This eliminates manual binding code and makes adding new types simple by using the same language at compile time and runtime.

C Interop That Just Works

Zig’s C interop is a first-class feature: you can directly import C header files and call C functions without wrapper libraries.

For example, we use cURL as our HTTP library. We can just import libcurl C headers in Zig and use the C functions directly:

pub const c = @cImport({ @cInclude("curl/curl.h"); }); pub fn init() !Http { try errorCheck(c.curl_global_init(c.CURL_GLOBAL_SSL)); errdefer c.curl_global_cleanup(); // business logic ... }

It feels as simple as using C, except you are programming in Zig.

And with the build system it’s also very simple to add the C sources to build everything together (your zig code and the C libraries):

fn buildCurl(b: *Build, m: *Build.Module) !void { const curl = b.addLibrary(.{ .name = "curl", .root_module = m, }); const root = "vendor/curl/"; curl.addIncludePath(b.path(root ++ "lib")); curl.addIncludePath(b.path(root ++ "include")); curl.addCSourceFiles(.{ .flags = &.{ // list of compilation flags (optional) }, .files = &.{ // list of C source files }}); }

This simplicity of importing C mitigates the fact that the Zig ecosystem is still small, as you can use all the existing C libraries.

The Build System Advantage

Zig includes its own build system written in Zig itself. This might sound unremarkable, but compared to CMake, it’s refreshingly straightforward. Adding dependencies, configuring compilation flags, and managing cross-compilation all happen in one place with clear semantics. Runtime, comptime, build system: everything is in Zig, which makes things easier.

Cross-compilation in particular is usually a difficult topic, but it’s very easy with Zig. Some projects like Uber use Zig mainly as a build system and toolchain.

Compile times matter

Zig compiles fast. Our full rebuild takes under a minute. Not as fast as Go or an interpreted language, but enough to have a feedback loop that makes development feel responsive. In that regard, Zig is considerably faster than Rust or C++.

This is a strong focus of the Zig team. They are also a small team and they need fast compilation for the development of the language, as Zig is written in Zig (self-hosted). For that purpose, they are developing native compiler backends (i.e. not using LLVM), which is very ambitious and yet successful: it’s already the default backend for x86 in debug mode, with a significant improvement in build times (3.5x faster for the Zig project itself). And incremental compilation is on its way.

What We’ve Learned

After months of building Lightpanda in Zig, here’s what stands out.

  • The learning curve is manageable. Zig’s simplicity means you can understand the entire language in a few weeks. Compared to Rust or C++, this makes a real difference.
  • The allocator model pays off. Being able to create arena allocators per page load, per request, or per task gives us fine-grained memory control without tracking individual allocations.
  • The community is small but helpful. Zig is still growing. The Discord community and ziggit.dev are active, and the language is simple enough that you can often figure things out by reading the standard library source.

Conclusion

Lightpanda wouldn’t exist without the work of the Zig Foundation and the community behind it. Zig has made it possible to build something as complex as a browser with a small team and a clear mental model, without sacrificing performance.

FAQ

Is Zig stable enough for production use?

Zig is still pre-1.0, which means breaking changes can happen between versions. That said, we’ve found it stable enough for our production use, especially since the ecosystem has largely standardized on tracking the latest tagged releases rather than main. The language itself is well-designed, and most changes between versions are improvements that are worth adapting to. Just be prepared to update code when upgrading Zig versions.

What’s the hardest part about learning Zig?

The allocator model takes adjustment if you’re coming from garbage-collected languages. You need to think about where memory comes from and when it gets freed. But compared to Rust’s borrow checker or C++‘s memory management, it’s relatively straightforward once you understand the patterns.

Can Zig really replace C++ for browser development?

For building a focused browser like Lightpanda, yes. For replacing Chromium or Firefox, that’s unlikely: those projects have millions of lines of C++ and decades of optimization. We’re more likely to see Rust complementing C++ in those projects over time, for example how Firefox is leveraging Servo. But for new projects where you control the codebase, Zig is absolutely viable.

Where can I learn more about Zig?

Start with the official Zig documentation. The Zig Learn site provides practical tutorials. And join the community on Discord or ziggit.dev where developers actively help newcomers. The language is simple enough that reading standard library source code is also a viable learning approach.


Francis Bouvier

Francis Bouvier

Cofounder & CEO

Francis previously cofounded BlueBoard, an ecommerce analytics platform acquired by ChannelAdvisor in 2020. While running large automation systems he saw how limited existing browsers were for this kind of work. Lightpanda grew from his wish to give developers a faster and more reliable way to automate the web.