Zig for the Last 5%: 6 Months with Rust + Go (Honest Verdict)
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:
| Metric | Rust (original) | Zig (rewritten) | Improvement |
|---|---|---|---|
| Encoding latency (p99) | 120μs | 78μs | 35% faster |
| Allocations per request | 2.3 | 1.0 | 57% fewer |
| Binary size (this function) | 8.4 KB | 3.2 KB | 62% smaller |
The gains come from three things:
- 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
bumpaloortyped-arena), but it’s not the default pattern. - Explicit allocator passing — the
allocatorparameter makes allocation strategy visible in the function signature. In Rust,Vec::new()silently uses the global allocator unless you opt into a custom one. - Struct-of-Arrays memory layout — the
FeatureTablestores indices and weights in separate contiguous arrays, improving cache hit rates. Rust’s defaultstruct { 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:
- 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 RustVecdrops, the memory is freed. Zig doesn’t allocate anything that Rust needs to free. - 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 Rustenumwiththiserrorfor clean error handling. - C-compatible types only — no Zig slices (
[]const u8), no RustString. 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:
| Metric | Spring Boot (baseline) | Go + Rust (3mo) | Go + Rust + Zig (6mo) |
|---|---|---|---|
| Containers | 190 | 24 | 26 |
| P99 latency | 340ms | 18ms | 16ms |
| Peak memory | 12 GB | 1.8 GB | 1.9 GB |
| Monthly cost | $4,200 | $1,800 | $1,850 |
| CI build time | 4:30 | 2:15 | 2:45 |
| Languages | 1 | 2 | 3 |
| Team members comfortable | 8/8 | 6/8 | 4/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
| Scenario | Recommendation | Why |
|---|---|---|
| CRUD API, admin dashboard, internal tool | Go | Fast to write, easy to debug, excellent stdlib |
| Data pipeline, ML inference, real-time processing | Rust | Safety + speed, mature ecosystem (Tokio, sqlx) |
| Byte-level parsing, custom allocators, zero-copy | Zig | No abstraction tax, explicit memory control |
| Team is primarily Java/Kotlin | Go first | Easiest transition, familiar C-style syntax |
| Performance-critical but needs ecosystem | Rust | Best balance of speed + libraries |
| You need to squeeze the last 10% of performance | Zig | But only if the 10% matters to your users |
| You’re starting a new greenfield service | Go | Ship fast, optimize later |
| You need to replace a Java hot path | Rust | Proven pattern from our first article |
| You need a drop-in C replacement | Zig | zig 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 ecosystem — GraalVM 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:
- Rust + Go Hybrid Architectures: What 6 Months Taught Me About Java’s Middle Ground — The original 80/15 split before Zig entered the picture
- What 6 Months of Rust Taught Me About Java’s Blind Spots — Why we started the polyglot journey
- Spring Boot + Testcontainers: Why I Stopped Writing Unit Tests — The Java baseline we started from
- GraalVM Native Image in Production: 6 Months Later — Why GraalVM wasn’t enough for our hot paths
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