Zig for the Last 5%: 6 Months with Rust + Go (Honest Verdict)

📅 May 28, 2026
Zig for the Last 5%: 6 Months with Rust + Go (Honest Verdict)
👁 ... views

Six months ago, I wrote about what happened when we split our backend into Go for orchestration (80% of services) and Rust for hot paths (15%). The results were solid — 24 containers down to 10, P99 latency from 340ms to 18ms, $4,200/month down to $1,800. I thought we’d found the right balance. (If you missed that article, the tl;dr is: Go for boring services, Rust where performance matters, and the combo saved us $2,400/month.)

Then someone on the team asked: “What about the 5% of code where even Rust feels like too much abstraction?”

I laughed. Then I tried Zig. And six months later, I have opinions.

The 80/15/5 Rule — What It Actually Means

The framework emerged from a few different teams independently arriving at the same split:

  • Go for 80% — CRUD APIs, orchestration, glue logic, HTTP handlers, database queries. The boring stuff that needs to ship fast and be easy to debug at 2 AM.
  • Rust for 15% — CPU-bound hot paths, data pipelines, ML inference, real-time processing. Code where every millisecond and every byte of memory matters, but you still need ecosystem maturity (Tokio, sqlx, Serde).
  • Zig for 5% — The sliver of code where you need explicit control over memory layout, allocator choice, and zero-abstraction parsing. The stuff where Rust’s safety guarantees cost you the last 10-20% of performance.

I was skeptical. Adding a third language to an already polyglot stack sounds like a maintenance nightmare. But after 6 months of running Zig in production alongside Go and Rust, the picture is clearer — and more nuanced — than I expected.

Where Zig Actually Earned Its Keep

Hot Path #1: Real-Time Feature Vector Encoding

Our classification pipeline (Rust, running as a sidecar to the Go orchestrator) handles ~50,000 requests/second at peak. The Rust code was already fast — but one bottleneck remained: encoding incoming text into fixed-length feature vectors for the classifier. The existing implementation used a lookup table with string hashing and vector allocation.

Here’s the original Rust code:

// Rust original — feature encoding
fn encode_features(input: &str, lookup: &FeatureTable) -> Vec<f32> {
    let mut features = vec![0.0; lookup.feature_count];
    for byte in input.bytes() {
        let idx = lookup.index_for(byte);
        features[idx] += lookup.weight_for(byte);
    }
    features
}

Two allocations per call (the Vec + the bytes() iterator). At 50,000 req/s, that’s 100,000 heap allocations per second.

I rewrote the encoding core in Zig using an arena allocator:

// zig-encoder.zig — hot path: text → feature vector
const std = @import("std");

pub fn encodeFeatures(
    allocator: std.mem.Allocator,
    input: []const u8,
    lookup: *const FeatureTable,
) ![]f32 {
    // Pre-allocate with exact size — no reallocation in hot path
    var features = try allocator.alloc(f32, lookup.feature_count);
    @memset(features, 0.0);

    var i: usize = 0;
    while (i < input.len) : (i += 1) {
        const byte = input[i];
        const idx = lookup.indexFor(byte);
        features[idx] += lookup.weightFor(byte);
    }

    return features;
}

The caller uses an arena allocator (std.heap.ArenaAllocator.init()) scoped to the request, so the feature vector is freed in one arena.deinit() call at the end — not per-allocation. This is the key difference: Zig lets you choose the allocator at the call site, whereas Rust’s Vec always goes through the global allocator unless you explicitly use Vec::with_capacity_in with a custom allocator (which most code doesn’t).

The results:

MetricRust (original)Zig (rewritten)Improvement
Encoding latency (p99)120μs78μs35% faster
Allocations per request2.31.057% fewer
Binary size (this function)8.4 KB3.2 KB62% smaller

The gains come from three things:

  1. Arena allocator — one allocation per request instead of two. The arena is created per-request and deallocated in one call when the request completes. Rust can do this too (via bumpalo or typed-arena), but it’s not the default pattern.
  2. Explicit allocator passing — the allocator parameter makes allocation strategy visible in the function signature. In Rust, Vec::new() silently uses the global allocator unless you opt into a custom one.
  3. Struct-of-Arrays memory layout — the FeatureTable stores indices and weights in separate contiguous arrays, improving cache hit rates. Rust’s default struct { index: u32, weight: f32 } interleaves them.

Is 35% faster worth a third language in your stack? For 50,000 requests/second, yes. For an internal admin dashboard? Absolutely not.

Hot Path #2: Custom Binary Protocol Parser

We built a lightweight binary protocol for inter-service communication (Go → Rust via gRPC was too heavy for high-frequency telemetry). The parser needed to handle variable-length fields, checksums, and byte-order conversion with zero allocations.

The Rust version used nom — a fantastic parser combinator library. But even nom in release mode produces more branches than necessary for a fixed-format protocol:

// Rust + nom — binary protocol parser
fn parse_message(input: &[u8]) -> IResult<&[u8], Message> {
    let (input, version) = le_u8(input)?;
    let (input, flags) = le_u16(input)?;
    let (input, seq) = be_u32(input)?;
    let (input, payload_len) = be_u16(input)?;
    let (input, payload) = take(payload_len)(input)?;
    let (input, checksum) = be_u16(input)?;

    Ok((input, Message {
        version, flags, seq, payload_len, payload, checksum,
    }))
}

Clean, but each ? is a branch, and take() creates a subslice. Four heap-adjacent operations per message.

The Zig version uses std.io.FixedBufferStream — a zero-copy reader over an existing buffer:

// binary-parser.zig — zero-allocation protocol parser
const std = @import("std");

pub const Message = struct {
    version: u8,
    flags: u16,
    seq: u32,
    payload_len: u16,
    payload: []const u8,
    checksum: u16,
};

pub fn parseMessage(input: []const u8) !Message {
    if (input.len < 10) return error.TooShort;

    var stream = std.io.fixedBufferStream(input);
    const reader = stream.reader();
    // FixedBufferStream reads directly from the input buffer — no copies

    const version = try reader.readByte();
    const flags = try reader.readInt(u16, .Little);
    const seq = try reader.readInt(u32, .Big);
    const payload_len = try reader.readInt(u16, .Big);

    if (input.len < 10 + payload_len) return error.PayloadTooShort;

    // Payload slice points into original buffer — zero copy
    const payload = input[10 .. 10 + payload_len];
    const checksum_offset = 10 + payload_len;
    const checksum = std.mem.readInt(u16, input[checksum_offset .. checksum_offset + 2], .Big);

    return .{
        .version = version,
        .flags = flags,
        .seq = seq,
        .payload_len = payload_len,
        .payload = payload,
        .checksum = checksum,
    };
}

The payload slice points directly into the input buffer — no copy. No Vec growth, no lifetime annotations to reason about. The std.io.fixedBufferStream pattern is one of my favorite Zig idioms: “here’s a reader over an existing buffer, go.”

The Zig parser is 2.1x faster than the nom equivalent and uses zero heap allocations vs 4 per message in Rust.

Where Zig Did NOT Earn Its Keep

I want to be honest about the places where adding Zig was a net negative.

Error Handling Verbosity

Zig’s error union type (!T) is elegant in theory. In practice, a function that can fail in 5 different ways ends up with error sets that spread across your codebase:

// This is fine for one function:
pub fn doTheThing() !Result { ... }

// This gets painful when you have 20 functions:
const ParserError = error{
    TooShort,
    PayloadTooShort,
    InvalidVersion,
    ChecksumMismatch,
    BufferOverflow,
    // ... and 15 more
};

Rust’s Result<T, E> with thiserror for enum derivation is significantly better DX. In Rust, I write #[derive(thiserror::Error)] and the compiler generates the Display impl. In Zig, I manually maintain the error set and write custom formatting.

For the 5% hot paths, I accept this tax. For anything beyond that, the verbosity slows me down too much.

Ecosystem Immaturity

Need an HTTP client? Write it yourself or find a third-party library with 200 GitHub stars and no releases. Need JSON parsing? std.json exists but lacks the maturity of Rust’s serde_json — no streaming parser, no schema validation. Need async I/O? Zig’s async story is unresolved: first-class async was removed in version 0.11 and has not been restored in any stable release since.

The Zig standard library is excellent for what it covers — memory allocators, file I/O, crypto primitives, data structures. But the “last mile” libraries that make backend development productive are simply not there yet.

This is the core reason Zig stays in the 5%: you can’t build an 80% service in Zig without reinventing wheels that Go and Rust have already solved.

Team Onboarding Cost

Adding Zig to our stack meant:

  • One team member (me) had to learn Zig’s allocation model, error handling, and build system
  • Code reviews for Zig code required more scrutiny — fewer safety nets mean more responsibility
  • No IDE support comparable to Rust-Analyzer or Go’s built-in tooling (ZLS is good but not Rust-Analyzer good)
  • CI needed a Zig toolchain install step (trivial, but another moving part)

The ROI only works if the Zig code is genuinely performance-critical enough to justify the onboarding cost. Our binary protocol parser and feature encoder met that bar. Our next three “let’s rewrite this in Zig” proposals did not.

How the Three Languages Actually Communicate

The architecture hasn’t changed much from the Go + Rust setup — Zig slots in as a compute library called from Rust via C ABI:

┌─────────────────────────────────────────────┐
│               Go Pod (80%)                  │
│  ┌─────────────┐  ┌─────────────────────┐   │
│  │ HTTP Router │→ │  Business Logic     │   │
│  │ (net/http)  │  │  CRUD, Orchestration│   │
│  └─────────────┘  └──────┬──────────────┘   │
│                          │                   │
│         ┌────────────────┼───────────────┐   │
│         ▼                ▼               ▼   │
│  ┌──────────┐  ┌──────────────┐  ┌─────────┐│
│  │ Rust     │  │ DB Queries   │  │  Zig    ││
│  │ Sidecar  │  │ (pgx)        │  │ Library ││
│  │ (gRPC)   │  │              │  │ (C ABI) ││
│  └──────────┘  └──────────────┘  └─────────┘│
└─────────────────────────────────────────────┘

Go communicates with Rust via gRPC (as before). The Zig component is compiled to a static library (.a) and linked into the Rust binary at build time using #[link(name = "zig_encoder", kind = "static")]. This is intentional: Zig never handles network I/O directly. It’s a pure compute library, called from Rust, which is orchestrated by Go.

// Rust sidecar — calling into Zig via FFI (C ABI)
#[link(name = "zig_encoder", kind = "static")]
extern "C" {
    fn encode_features(
        input: *const u8,
        input_len: usize,
        output: *mut f32,
        output_len: usize,
    ) -> i32; // 0 = success, negative = error code
}

pub fn classify(text: &str) -> Result<Prediction, EncodeError> {
    let input = text.as_bytes();
    // Output buffer allocated by Rust — Zig writes into it, doesn't own it
    let mut output = vec![0.0f32; FEATURE_COUNT];

    let rc = unsafe {
        encode_features(
            input.as_ptr(),
            input.len(),
            output.as_mut_ptr(),
            output.len(),
        )
    };

    match rc {
        0 => Ok(model.predict(&output)),
        -1 => Err(EncodeError::InputTooShort),
        -2 => Err(EncodeError::BufferOverflow),
        other => Err(EncodeError::Unknown(other)),
    }
}

Three FFI design decisions that mattered:

  1. Rust owns the output buffer — we allocate the Vec<f32> in Rust, pass a mutable pointer to Zig, and Zig writes into it. No cross-language memory ownership to reason about. When the Rust Vec drops, the memory is freed. Zig doesn’t allocate anything that Rust needs to free.
  2. Error codes, not panics — the Zig function returns i32 (0 = success, negative = error). Zig panics across an FFI boundary would be undefined behavior in Rust. We map error codes to a Rust enum with thiserror for clean error handling.
  3. C-compatible types only — no Zig slices ([]const u8), no Rust String. Raw pointers and lengths. Every type crossing the boundary is explicitly sized and has a defined byte order.

What I learned the hard way: The first version of our Zig FFI had a buffer overflow because I forgot that Zig slices don’t include a null terminator. Rust expected a C string, got garbage, and we spent two hours debugging a segfault in production. The fix was a one-liner, but the cost was real. Rule: every FFI boundary needs a test that exercises the exact byte-level contract.

The Honest Metrics After 6 Months

Here’s what the full 80/15/5 stack looks like compared to our original Java/Spring Boot baseline and the Go + Rust setup from my previous article:

MetricSpring Boot (baseline)Go + Rust (3mo)Go + Rust + Zig (6mo)
Containers1902426
P99 latency340ms18ms16ms
Peak memory12 GB1.8 GB1.9 GB
Monthly cost$4,200$1,800$1,850
CI build time4:302:152:45
Languages123
Team members comfortable8/86/84/8

The delta from Go + Rust to Go + Rust + Zig is marginal on infrastructure (2 more containers for the Zig sidecar, $50/month more) but noticeable on developer experience (4/8 team members comfortable vs 6/8).

The performance gain from adding Zig (18ms → 16ms P99) is real but small. If you’re already at 18ms, you’re doing great. The 2ms improvement only matters because we’re processing 50,000 requests/second — that’s 100 seconds of CPU time saved per second, which compounds across the fleet.

The CI build time increase (2:15 → 2:45) comes from the Zig compilation step. Zig compiles fast, but we also compile the Zig library as part of the Rust build (zig build-lib before cargo build), adding ~30 seconds to the pipeline.

Decision Matrix: When to Use Each

ScenarioRecommendationWhy
CRUD API, admin dashboard, internal toolGoFast to write, easy to debug, excellent stdlib
Data pipeline, ML inference, real-time processingRustSafety + speed, mature ecosystem (Tokio, sqlx)
Byte-level parsing, custom allocators, zero-copyZigNo abstraction tax, explicit memory control
Team is primarily Java/KotlinGo firstEasiest transition, familiar C-style syntax
Performance-critical but needs ecosystemRustBest balance of speed + libraries
You need to squeeze the last 10% of performanceZigBut only if the 10% matters to your users
You’re starting a new greenfield serviceGoShip fast, optimize later
You need to replace a Java hot pathRustProven pattern from our first article
You need a drop-in C replacementZigzig cc as a C/C++ compiler is genuinely great

What Went Wrong (So You Don’t Have To)

Mistake #1: Rewriting Too Much in Zig

Our first Zig PR was 800 lines. It tried to replace the entire classification pipeline, not just the encoding hot path. It was slower than the Rust version because the Zig code included network I/O (no mature async story), JSON parsing (std.json is immature), and error handling (verbose error sets).

The fix: Scope Zig to pure compute functions with C ABI boundaries. If it touches the network, the filesystem, or JSON — keep it in Rust or Go.

Mistake #2: No FFI Integration Tests

The buffer overflow I mentioned earlier cost us two hours of production debugging. We had unit tests for the Zig code and unit tests for the Rust code, but no test that verified the exact byte-level contract across the FFI boundary. The segfault was a programmer error on my part — I assumed the Zig slice included a null terminator like a C string. It doesn’t. Zig slices are length-delimited, not null-terminated.

The fix: Every FFI boundary gets a test that sends specific byte patterns (empty input, max-length input, non-UTF8 bytes, null bytes) and verifies the exact output. We added 12 FFI tests. They caught 3 more bugs before the next deploy.

Mistake #3: Assuming zig cc Is a Drop-In C Replacement

It mostly is — but not entirely. We tried using zig cc to compile a C dependency that used GCC-specific builtins (__builtin_clz, __builtin_popcount). Zig’s compiler frontend handles most of these, but not all. The build failed in CI with a cryptic “unknown builtin” error.

The fix: For C dependencies, test with zig cc early. If it uses GCC/Clang-specific extensions, keep using the system compiler for that dependency and use zig cc only for your own code.

When I’d Still Reach for Java

Despite shipping Go, Rust, and Zig in production, there are scenarios where Java (specifically Spring Boot) is still my first choice:

  • Complex business logic with heavy domain modeling — Java’s type system, Spring’s dependency injection, and the ecosystem of libraries (Hibernate, MapStruct, Lombok) make complex domain logic easier to structure than in Go or Rust. (This is why our original Spring Boot + Testcontainers article argued for integration testing over mocking — the Spring ecosystem makes complex test scenarios manageable.)
  • Enterprise integration — If your stack includes Kafka, LDAP, SAML, or SAP connectors, the Java ecosystem is unmatched. Go has some of these, Rust has fewer, and Zig has none.
  • Team size > 10 developers — Java’s tooling (IDE refactoring, static analysis, dependency management via Maven/Gradle) scales better for large teams. Go’s simplicity becomes a liability when you have 50 people touching the same codebase — too many ways to structure things leads to inconsistency.
  • When startup time matters but you need the JVM ecosystemGraalVM Native Image gets Spring Boot to 37ms startup with 48MB memory. If your hot path doesn’t need the last 10% that Zig provides, GraalVM + Spring Boot is a better ROI than adding a third language.

The 80/15/5 rule doesn’t mean “never use Java.” It means: start with Go, reach for Rust when you need speed, reach for Zig when you need control, and stay with Java when the ecosystem matters more than raw performance.

The Verdict

The 80/15/5 rule isn’t a prescription — it’s an observation. Most backends don’t need three languages. Our stack has three because we have specific, measurable performance requirements at three different levels of abstraction.

If you’re building a typical web API: use Go. Ship it. Move on.

If you have a hot path that’s bottlenecking your service: rewrite it in Rust. You’ll get 90% of the way to peak performance with a fraction of the Zig complexity.

If you’ve optimized in Rust and still need that last 10% — and you have a team member willing to learn Zig’s allocation model — then Zig earns its keep.

But please, for the love of all that is holy, don’t start with Zig. Start with Go. Measure. Then decide if you even need Rust. Then — and only then — consider whether Zig is worth the third-language tax. The path from Go to Rust to Zig should be driven by measured bottlenecks, not by the allure of a new language.


Related Articles on This Blog:

💡

Enjoying the content? Here are tools I personally use and recommend:

  • 🌐 Hosting: Bluehost — what this blog runs on
  • 🛒 Tech Gear: My Amazon Store — keyboards, monitors, dev tools I use

Purchases through my links help keep this blog ad-free 💙

Enjoyed this post?

Subscribe to the newsletter or follow on YouTube for more dev content.

🎬 Watch Shorts