Memory Management

March 13, 2026 · View on GitHub

Zigly functions that allocate memory require an explicit allocator. This document covers allocator patterns for edge services.

Why Explicit Allocators?

Zig doesn't have a global allocator. Functions that need to allocate memory take an Allocator parameter:

// Allocates memory for the header value
const user_agent = try request.headers.get(allocator, "User-Agent");

This makes allocation explicit and controllable, which matters in memory-constrained WebAssembly environments.

Allocator Types

Page Allocator

The simplest choice for direct memory allocation:

const allocator = std.heap.page_allocator;
const data = try allocator.alloc(u8, 1024);
defer allocator.free(data);

On WebAssembly targets, page_allocator automatically uses WasmAllocator under the hood. This is a WASM-optimized allocator that:

  • Uses 64KB bigpages matching WASM memory page size
  • Maintains free lists for efficient memory reuse
  • Grows memory via @wasmMemoryGrow

This means you get WASM-optimized allocation without changing your code. The allocator is fast but doesn't track allocations for leak detection.

Note: std.heap.wasm_allocator is identical to page_allocator on WASM targets. Using page_allocator is preferred because it keeps your code portable for native testing.

Debug Allocator

Tracks allocations and detects leaks in debug builds:

var debug_alloc = std.heap.DebugAllocator(.{}).init(std.heap.page_allocator);
defer {
    const status = debug_alloc.deinit();
    if (status == .leak) {
        std.debug.print("Memory leak detected!\n", .{});
    }
}
const allocator = debug_alloc.allocator();

Slower than page allocator but helps find memory issues during development.

Arena Allocator

Allocates from a growing region, frees everything at once:

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();  // Frees all allocations
const allocator = arena.allocator();

// These allocations are freed together when arena is deinitialized
const header1 = try request.headers.get(allocator, "Host");
const header2 = try request.headers.get(allocator, "User-Agent");
const body = try request.body.readAll(allocator, 0);
// No individual free() calls needed

Arenas are ideal for request handling—allocate throughout the request, free everything at the end.

Patterns

Request-Scoped Arena

The recommended pattern for most services:

fn start() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    var downstream = try zigly.downstream();

    // All allocations use the arena
    const user_agent = try downstream.request.headers.get(allocator, "User-Agent");
    const body = try downstream.request.body.readAll(allocator, 0);

    // Process request...

    try downstream.response.setStatus(200);
    try downstream.response.finish();
}
// Arena frees everything when function returns

Scoped Allocations

Use inner arenas for temporary work:

fn start() !void {
    var main_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer main_arena.deinit();
    const allocator = main_arena.allocator();

    var downstream = try zigly.downstream();

    // Temporary processing with inner arena
    {
        var temp_arena = std.heap.ArenaAllocator.init(allocator);
        defer temp_arena.deinit();
        const temp = temp_arena.allocator();

        const large_data = try fetchLargeData(temp);
        const result = processData(large_data);
        // large_data freed here when temp_arena is deinitialized
    }

    // Continue with main allocator
}

Fixed Buffers

For known-size data, use stack buffers:

fn start() !void {
    var downstream = try zigly.downstream();

    // Stack buffer for method (methods are short)
    var method_buf: [16]u8 = undefined;
    const method = try downstream.request.getMethod(&method_buf);

    // Stack buffer for URI
    var uri_buf: [4096]u8 = undefined;
    const uri = try downstream.request.getUriString(&uri_buf);

    // No allocation needed for these
}

Memory-Aware Functions

Some Zigly functions allocate, others use provided buffers:

// ALLOCATES - returns owned memory
const header = try request.headers.get(allocator, "Host");  // allocator required

// BUFFER - writes to provided buffer
var method_buf: [16]u8 = undefined;
const method = try request.getMethod(&method_buf);  // no allocator

// BUFFER - writes to provided buffer
var uri_buf: [4096]u8 = undefined;
const uri = try request.getUriString(&uri_buf);  // no allocator

Prefer buffer-based functions when you know the maximum size.

Reading Bodies

Bodies can be large. Control memory usage:

// Read entire body (for small bodies)
const body = try request.body.readAll(allocator, 0);  // 0 = no limit

// Limit maximum size
const body = try request.body.readAll(allocator, 1024 * 1024);  // 1MB max

// Streaming read (for large bodies)
var buf: [4096]u8 = undefined;
while (true) {
    const chunk = try request.body.read(&buf);
    if (chunk.len == 0) break;
    // Process chunk...
}

WebAssembly Memory Limits

WebAssembly linear memory starts small and grows on demand. Be aware of:

  • Initial memory: Usually 1-4 pages (64KB each)
  • Maximum memory: Platform-dependent, typically 256MB-4GB
  • Growth: Memory can grow but never shrink
  • Page size: WASM pages are always 64KB

The WasmAllocator (used by page_allocator on WASM) is optimized for this model, using 64KB bigpages that align with WASM memory pages.

Large allocations can fail if memory can't grow:

const huge = allocator.alloc(u8, 100 * 1024 * 1024) catch |err| {
    // Handle out of memory
    return err;
};

Avoiding Leaks

Always Defer Cleanup

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();  // Always runs, even on error

Close Resources

var entry = try cache.lookup("key", .{});
defer entry.close() catch {};

var body = try entry.getBody(null);
defer body.close() catch {};

Debug Allocator for Development

Use DebugAllocator during development to detect leaks:

fn start() !void {
    var debug_alloc = std.heap.DebugAllocator(.{}).init(std.heap.page_allocator);
    defer {
        const status = debug_alloc.deinit();
        if (status == .leak) {
            std.debug.print("Memory leak detected!\n", .{});
        }
    }
    const allocator = debug_alloc.allocator();
    // ...
}

The leak detection only works locally (with std.debug.print), not in production.

Performance Tips

  1. Use arenas for request-scoped allocations
  2. Reuse buffers for repeated operations
  3. Limit body sizes to prevent memory exhaustion
  4. Prefer stack buffers for small, fixed-size data
  5. Stream large bodies instead of loading entirely

Example: Efficient Request Handler

const std = @import("std");
const zigly = @import("zigly");

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const alloc = arena.allocator();

    var downstream = try zigly.downstream();

    // Stack buffers for small data
    var method_buf: [16]u8 = undefined;
    const method = try downstream.request.getMethod(&method_buf);

    var uri_buf: [4096]u8 = undefined;
    const uri = try downstream.request.getUriString(&uri_buf);

    // Arena for variable-size data
    const headers = try downstream.request.headers.names(alloc);

    // Limit body size
    const body = try downstream.request.body.readAll(alloc, 64 * 1024);  // 64KB max

    // Process and respond
    try downstream.response.setStatus(200);
    try downstream.response.finish();
}
// Arena frees everything when main() returns

Next Steps