Lightpanda Zig lightpanda-io/browser

Zig Memory Management for a Browser

From the GPA-vs-c_allocator split in main, through the per-page arena pool, to the libcurl allocator boundary and the v8 memory pressure hints.

6 stops ~18 min Verified 2026-05-04
What you will learn
  • Why Lightpanda picks DebugAllocator in Debug and c_allocator in Release
  • How a per-page arena from ArenaPool gives a Page one allocator that frees on navigation
  • How ArenaPool buckets arenas by retain size and reuses them across pages
  • How ZigToCurlAllocator fronts curl's malloc/calloc/realloc/free with a Zig Allocator
  • How errdefer chains in App.init keep partial initialization from leaking
  • How Page.deinit and Runner pump v8's memory pressure hints to keep RSS flat
Prerequisites
  • Comfort reading Zig allocators, errdefer, and arenas
  • Familiarity with C-side malloc/free semantics
  • No prior knowledge of v8's memory model required
1 / 6

main.zig: Two Allocators, Different Jobs

src/main.zig:30

Debug builds use a leak-detecting GPA, release builds use the C allocator, and both feed an arena for short-lived setup.

The browser does two kinds of allocation. Long-lived state (the v8 isolate, the curl multi handle, the session storage) lives for the whole process; short-lived setup (parsed CLI args, the sighandler struct) lives only until main returns. Lightpanda gives them different allocators. The general-purpose allocator is conditional: in Debug it is std.heap.DebugAllocator with leak detection and a 10-frame stack capture, in Release it is std.heap.c_allocator, which forwards to the system malloc.

The main_arena on top of the GPA is the cheap part. Anything main itself allocates goes through the arena, and one arena.deinit() on the way out frees it all without per-allocation accounting. The GPA underneath is the source of truth for leak detection: gpa_instance.detectLeaks() on shutdown exits with status 1 if any GPA-allocated memory was not freed, which catches real leaks during tests without slowing down release builds.

Key takeaway

DebugAllocator in Debug catches leaks, c_allocator in Release stays fast, and a main-scoped arena absorbs short-lived setup allocations cheaply.

pub fn main() !void {
    // allocator
    // - in Debug mode we use the General Purpose Allocator to detect memory leaks
    // - in Release mode we use the c allocator
    var gpa_instance: std.heap.DebugAllocator(.{ .stack_trace_frames = 10 }) = .init;
    const gpa = if (builtin.mode == .Debug) gpa_instance.allocator() else std.heap.c_allocator;

    defer if (builtin.mode == .Debug) {
        if (gpa_instance.detectLeaks()) std.posix.exit(1);
    };

    // arena for main-specific allocations
    var main_arena_instance = std.heap.ArenaAllocator.init(gpa);
    const main_arena = main_arena_instance.allocator();
    defer main_arena_instance.deinit();

    run(gpa, main_arena) catch |err| {
        log.fatal(.app, "exit", .{ .err = err });
        std.posix.exit(1);
    };
}
2 / 6

Page.zig: One Arena Per Page Lifetime

src/browser/Page.zig:116

Page.init pulls a large arena from the session pool; Page.deinit returns it. Every DOM allocation between init and deinit lives on that arena.

A page navigates, runs scripts, builds a DOM tree, then the user navigates away and the page should release everything. Per-allocation refcounting on every DOM node would be slow and error-prone. Lightpanda picks the arena answer: one frame_arena per Page, taken from session.arena_pool.acquire(.large, ...), and every Document, Element, and Factory allocation routes through it.

The Factory on the next line is a DOM-object factory bound to the same arena, so creating a new Element allocates on frame_arena, never on the global GPA. The errdefer immediately after acquire handles the case where Frame.init below fails: the arena goes back to the pool instead of leaking. When Page.deinit runs at navigation time, one arena_pool.release(self.frame_arena) at the bottom of the function frees the whole tree in O(1).

Key takeaway

One arena per Page is acquired on init and released on deinit. The DOM tree never frees individual nodes; the page just hands its arena back.

// Initialize a Page and its root Frame.
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
    const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");
    errdefer session.arena_pool.release(frame_arena);

    self.* = .{
        .session = session,
        .frame = undefined,
        .frame_arena = frame_arena,
        .factory = Factory.init(frame_arena),
    };
    self.queued_navigation = &self.queued_navigation_1;

    try Frame.init(&self.frame, frame_id, self, null);
}
3 / 6

ArenaPool: Bucketed and Reusable

src/ArenaPool.zig:110

ArenaPool.acquire picks a bucket by size hint, reuses arenas off the free list, and tracks debug names for leak detection.

Allocating a fresh arena per page costs a virtual memory reservation; releasing one returns the reservation to the OS. A browser that crawls a thousand pages would do that a thousand times. ArenaPool avoids it by keeping per-bucket free lists. The four buckets (tiny, small, medium, large) each have a retain-bytes limit and a max free-list length, configured in ArenaPool.Config.

The acquire path takes either an explicit bucket (.large for a Page) or a size hint as a usize (which routes to the smallest bucket whose retain limit covers the hint). On release, the arena is reset with retain_with_limit = bucket.retain_bytes rather than fully deinited, so the next acquire reuses the underlying memory blocks. The debug string is a leak-tracking key: in Debug builds, _leak_track increments on acquire and decrements on release, and deinit panics if any name is non-zero.

Key takeaway

The pool keeps four buckets, four free lists, and one debug-string leak tracker. Acquiring an arena reuses memory across pages; releasing resets to a retain limit.

pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Allocator {
    const bucket = blk: {
        const T = @TypeOf(size_or_bucket);
        if (T == BucketSize or T == @TypeOf(.enum_literal)) {
            break :blk switch (@as(BucketSize, size_or_bucket)) {
                .tiny => &self.tiny,
                .small => &self.small,
                .medium => &self.medium,
                .large => &self.large,
            };
        }
        if (T == usize or T == comptime_int) {
            if (size_or_bucket <= self.tiny.retain_bytes) break :blk &self.tiny;
            if (size_or_bucket <= self.small.retain_bytes) break :blk &self.small;
            if (size_or_bucket <= self.medium.retain_bytes) break :blk &self.medium;
            break :blk &self.large;
        }
        @compileError("acquire expects BucketSize or usize, got " ++ @typeName(T));
    };
4 / 6

ZigToCurlAllocator: The FFI Boundary

src/network/Network.zig:99

ZigToCurlAllocator implements curl's malloc/calloc/realloc/free hooks, prefixing every block with a size header so realloc and free know what to do.

curl allocates in places Zig has never heard of: response header buffers, URL parsers, TLS session caches. Letting it call the system malloc would put those bytes outside the Zig allocator graph, invisible to the leak detector and uncoupled from the page's lifetime. ZigToCurlAllocator closes the gap. curl_global_init_mem takes function pointers for malloc, calloc, realloc, free, and strdup, and Lightpanda installs Zig-implemented versions through the interface() table.

The trick is the 16-byte Block header. Zig's std.mem.Allocator needs to know the size of an allocation to free or resize it; curl only hands back the user pointer. The header sits in front of every allocation, holds the byte count, and pads to max_align_t so user data is correctly aligned. fromPtr walks backward from the user pointer to recover the header on free and realloc. Now every byte curl uses flows through the same allocator the rest of the browser does.

Key takeaway

A 16-byte size header in front of every block lets curl's malloc/free/realloc hooks call into a Zig std.mem.Allocator. No allocation escapes the graph.

const ZigToCurlAllocator = struct {
    // C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64).
    // We match this guarantee since libcurl expects malloc-compatible alignment.
    const alignment = 16;

    const Block = extern struct {
        size: usize = 0,
        _padding: [alignment - @sizeOf(usize)]u8 = .{0} ** (alignment - @sizeOf(usize)),

        inline fn fullsize(bytes: usize) usize {
            return alignment + bytes;
        }

        inline fn fromPtr(ptr: *anyopaque) *Block {
            const raw: [*]u8 = @ptrCast(ptr);
            return @ptrCast(@alignCast(raw - @sizeOf(Block)));
        }

        inline fn data(self: *Block) [*]u8 {
            const ptr: [*]u8 = @ptrCast(self);
            return ptr + @sizeOf(Block);
        }
5 / 6

App.init: Six errdefers in Order

src/App.zig:46

Each step of App.init that allocates a resource registers an errdefer for its destructor on the next line, so any later failure unwinds cleanly.

Initializing a browser is a chain: v8 platform, then v8 snapshot, then disk storage, then the App struct, then the network with its curl multi handle, then the app data directory, then telemetry, then the arena pool. Any of these can fail. Without disciplined cleanup, a failure on step five leaks steps one through four.

Zig's errdefer turns this into a code-local pattern. The line right after each successful allocation registers the matching destructor, scoped to the function. If everything succeeds, the errdefers are dropped at the closing brace. If any later step returns an error, the errdefers fire in reverse order: telemetry, network, app struct, storage, snapshot, platform, all the way back. There is no exception unwinding, no destructor stack, no implicit anything; the cleanup is right next to the allocation that needed it.

Key takeaway

Allocate, errdefer-destroy, repeat. Every failure path in App.init unwinds through the errdefers in reverse order, with no leak surface.

pub fn init(allocator: Allocator, config: *const Config) !*App {
    const platform = try Platform.init();
    errdefer platform.deinit();

    const snapshot = try Snapshot.load();
    errdefer snapshot.deinit();

    var storage = try Storage.init(allocator, config);
    errdefer storage.deinit(allocator);

    const app = try allocator.create(App);
    errdefer allocator.destroy(app);

    app.* = .{
        .config = config,
        .allocator = allocator,
        .platform = platform,
        .snapshot = snapshot,
        .storage = storage,
        .network = undefined,
        .app_dir_path = undefined,
        .telemetry = undefined,
        .arena_pool = undefined,
    };
    app.network = try Network.init(allocator, app, config);
    errdefer app.network.deinit();
6 / 6

Memory Pressure Hints Keep RSS Flat

src/browser/Page.zig:133

Page.deinit hands back the frame_arena and tells v8 it is under moderate pressure, so wrappers and external references get freed.

The README's 123MB peak versus Chrome's 2GB peak is the result of doing two things at once: not building the parts that cost memory, and being aggressive about reclaiming the parts that exist. Page.deinit is the second half. The frame and its DOM go first, then the popups, then the per-page identity map for v8 wrapper objects, then the registered finalizer callbacks. Every step releases something the page allocated.

The defer session.browser.env.memoryPressureNotification(.moderate) is the nudge to v8. v8 will not run an idle GC just because some Zig code dropped its handles; it needs an explicit hint that the host wants memory back. moderate triggers an incremental collection without stalling. The matching .critical hint runs in Browser.closeSession when the whole session goes away. Combined with the frame_arena release that Frame.deinit cascades into, every page that finishes hands its memory back instead of letting it sit.

Key takeaway

The frame arena releases the DOM in O(1); a moderate v8 memory pressure hint releases the wrapper objects v8 holds. RSS stays flat across pages.

pub fn deinit(self: *Page) void {
    self.cleanupClosedPopups();

    for (self.popups.items) |popup| {
        popup.deinit();
    }
    self.popups = .empty;

    self.frame.deinit();

    const session = self.session;
    defer session.browser.env.memoryPressureNotification(.moderate);

    self.identity.deinit();
    self.identity = .{};

    // Force cleanup all remaining finalized objects.
    {
        var it = self.finalizer_callbacks.valueIterator();
        while (it.next()) |fc| {
            fc.*.deinit(self);
        }
        self.finalizer_callbacks = .empty;
    }
Your codebase next

Create code tours for your project

Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.

Install Intraview Free