zig lessons from building a bluesky feed generator#

notes from building a production feed generator in zig 0.15. things we learned, patterns that worked, mistakes made.

memory management#

arena allocators for request handling#

http requests are perfect for arena allocators - allocate freely during the request, free everything at once when done:

fn handleRequest(request: *http.Server.Request) !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const alloc = arena.allocator();

    // allocate freely - no individual frees needed
    const result = try processStuff(alloc);
    try sendResponse(request, result);
}
// arena.deinit() frees everything

errdefer for cleanup on error paths#

when building up state that needs cleanup on error:

var posts: std.ArrayList(AuthorPost) = .empty;
errdefer {
    for (posts.items) |p| {
        allocator.free(p.uri);
        allocator.free(p.cid);
    }
    posts.deinit(allocator);
}
// if anything below errors, cleanup runs automatically

allocator passed to methods (0.15 style)#

zig 0.15 changed ArrayList - allocator is passed to methods, not stored:

// old (pre-0.15)
var list = std.ArrayList(u8).init(allocator);
try list.append('x');

// new (0.15+)
var list: std.ArrayList(u8) = .empty;
try list.append(allocator, 'x');
defer list.deinit(allocator);

json handling#

runtime path navigation#

for one-off extractions, path-based navigation is clean:

const did = zat.json.getString(item, "post.author.did") orelse continue;
const text = zat.json.getString(item, "post.record.text") orelse "";

comptime struct extraction#

for repeated patterns, define a struct and extract at compile time:

const FeedPost = struct {
    uri: []const u8,
    cid: []const u8,
    record: struct {
        text: []const u8 = "",
        embed: ?struct {
            external: ?struct { uri: []const u8 } = null,
        } = null,
    },
};

// extracts nested structure, handles missing optionals
const post = zat.json.extractAt(FeedPost, allocator, item, .{"post"}) catch continue;

the struct mirrors the json shape. optional fields (?struct) handle missing keys gracefully. default values (= "") provide fallbacks.

std.json for raw parsing#

when you need the full json tree:

var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return error.ParseError;
defer parsed.deinit();

if (parsed.value != .object) return error.InvalidFormat;
const obj = parsed.value.object;
const kind = obj.get("kind") orelse return error.MissingField;

concurrency#

mutex for shared state#

simple mutex pattern for shared database access:

var db: ?zqlite.Conn = null;
var mutex: std.Thread.Mutex = .{};

pub fn addPost(uri: []const u8, cid: []const u8) !void {
    mutex.lock();
    defer mutex.unlock();

    const conn = db orelse return error.NotInitialized;
    conn.exec("INSERT ...", .{uri, cid}) catch |err| {
        return err;
    };
}

spawning threads#

const thread = std.Thread.spawn(.{}, jetstream.consumer, .{allocator}) catch |err| {
    std.debug.print("failed to spawn: {}\n", .{err});
    return;
};
thread.detach();

http patterns#

building responses with ArrayList#

var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(alloc);
const w = buf.writer(alloc);

try w.print("{{\"status\":\"{s}\",\"count\":{d}}}", .{status, count});

try sendJson(request, .ok, buf.items);

multiline strings for sql#

zig's multiline strings work well for embedded sql:

db.exec(
    \\CREATE TABLE IF NOT EXISTS posts (
    \\  uri TEXT PRIMARY KEY,
    \\  cid TEXT NOT NULL,
    \\  indexed_at INTEGER NOT NULL
    \\)
, .{}) catch |err| {
    return err;
};

error handling#

error unions with catch#

// return error on failure
const result = try riskyOperation();

// handle specific errors
const value = operation() catch |err| {
    std.debug.print("failed: {}\n", .{err});
    return error.OperationFailed;
};

// fallback on error
const maybe = operation() catch null;

sentinel errors for filtering#

use specific errors to distinguish "not found" from "actual failure":

fn processRecord(payload: []const u8) !void {
    // these are expected, not failures
    if (!isPost(record)) return error.NotAPost;
    if (!filter.matches(record)) return error.NoMatch;

    // this is an actual failure
    db.addPost(uri, cid) catch |err| {
        std.debug.print("db error: {}\n", .{err});
        return err;
    };
}

// caller can ignore expected "errors"
self.processRecord(data) catch |err| {
    if (err != error.NotAPost and err != error.NoMatch) {
        std.debug.print("processing error: {}\n", .{err});
    }
};

project structure#

adopted domain-based grouping after the codebase grew:

src/
  main.zig           # entry point, spawns threads
  feed/
    config.zig       # environment variables, feed URIs
    filter.zig       # post matching logic
  server/
    http.zig         # request handling
    dashboard.zig    # html rendering
    stats.zig        # metrics
  stream/
    jetstream.zig    # websocket consumer
    db.zig           # sqlite operations
    atproto.zig      # AT Protocol utilities

relative imports: @import("../feed/config.zig")

tooling#

zig fmt#

built-in formatter. run before commits:

zig fmt src/ build.zig

check mode for CI/hooks:

zig fmt --check src/ build.zig

build.zig dependencies#

fetch external packages:

zig fetch --save https://example.com/package/archive/main

creates entry in build.zig.zon, imports via @import("package").

things that surprised us#

  1. no hidden allocations - you always know when memory is allocated because you pass the allocator

  2. comptime is powerful - struct extraction, string formatting, type introspection all happen at compile time

  3. error handling is ergonomic - try, catch, errdefer compose well

  4. multiline strings preserve indentation - \\ prefix strips leading whitespace consistently

  5. optionals chain nicely - if (a) |b| if (b.c) |d| d.value else null else null

sdk adoption (zat)#

started with hand-rolled http/did resolution (~450 lines). adopted zat sdk:

  • zat.Did.parse() - DID parsing
  • zat.DidResolver - DID document resolution
  • zat.XrpcClient - XRPC API calls with automatic JSON handling
  • zat.json.getString() - path-based JSON navigation
  • zat.json.extractAt() - comptime struct extraction
  • zat.Tid, zat.AtUri, zat.Nsid - AT Protocol primitives

result: atproto.zig went from 898 lines to 454 lines (50% reduction).

lesson: find or build good abstractions - the raw http/tls/json code worked but was noisy. moving it to a library cleaned up the application code significantly.