The tax nobody talks about
Last year I was profiling a service that sat between two internal systems - a relay that received market event payloads, did some light enrichment, and forwarded them downstream. The actual business logic was maybe 40 lines of code. Trivial stuff.
But the CPU was pinned at 70% under moderate load. That didn’t add up.
When I cracked open the flame graph, the hottest frames weren’t in our code at all. They were buried deep inside Protobuf - ParseFromCodedStream, SerializeToCodedStream, and all the memory allocation machinery they drag in. We were spending more cycles packing and unpacking data than doing anything useful with it.
This is what I call the serialization tax, and most teams never isolate it because it hides inside library code, not your code. Every message your service receives has to be decoded from a wire format into native objects. Every message it sends has to be encoded back. For most applications at modest scale, this cost is a rounding error in your p99. But once you’re handling tens of thousands of messages per second, or your payloads grow beyond trivial sizes, or you’re chaining multiple services together - it compounds fast. It shows up as mysterious CPU pressure that no amount of business logic optimization will fix.
JSON is the worst offender (text parsing is inherently expensive), but even binary formats like Protobuf carry this overhead. Every single read still requires parsing a byte stream, executing conditional logic to determine field types, and allocating heap memory for the resulting objects.
That profiling session is what sent me down the Cap’n Proto rabbit hole. And what I found changed how I think about data in motion.
Enter Cap’n Proto
Cap’n Proto was built by Kenton Varda - the same engineer who designed and implemented Protocol Buffers v2 at Google. That context matters. He didn’t build Cap’n Proto because he disliked Protobuf; he built it because he’d spent years living inside Protobuf’s codebase and understood, at a mechanical level, exactly where the performance ceiling was and why it existed.
His diagnosis was simple: the bottleneck isn’t the encoding algorithm - it’s the fact that encoding exists at all.
So Cap’n Proto’s value proposition sounds absurd the first time you hear it: it’s “infinity times faster” than Protobuf at serialization. Not 2x. Not 10x. Infinity - because the serialization step literally does not happen. There is no encode. There is no decode.
That’s not marketing. It’s a direct consequence of the architecture. Let me show you what I mean.
The zero-copy revolution
The best way to understand Cap’n Proto is to see exactly what it eliminates.
The traditional model (what you’re probably running today)
With Protobuf, JSON, MessagePack, or most serialization formats, the data lifecycle has four stages:
- You build objects in memory using your language’s native structures - structs, classes, hash maps
- When it’s time to send, the library walks your entire object tree and encodes it into a flat byte sequence (the wire format). This involves type checking, varint encoding, field tag writing, and heap allocation for the output buffer
- Those bytes travel over the network
- On the receiving end, the library decodes the byte stream - parsing field tags, branching on wire types, allocating memory, and constructing entirely new native objects
Steps 2 and 4 are where your CPU burns. The encoder has to iterate, transform, and pack. The decoder has to parse, branch, and allocate. For a 500-byte message this takes microseconds. For a 50KB message repeated 100,000 times per second, it dominates your flame graph - just like it dominated mine.
What Cap’n Proto does differently
Here’s the mental model shift. Think about mailing a letter. With traditional serialization, you write your thoughts in English (in-memory objects), then painstakingly translate them into Morse code (encoding), mail the dots and dashes, and the recipient translates the Morse code back into English (decoding). Two expensive translation steps bracket every single message.
Cap’n Proto’s approach: what if you just think in Morse code from the start? If the language you compose in IS the language that travels over the wire, there’s nothing to translate. You just… send it.
That’s literally what Cap’n Proto does. The in-memory representation IS the wire format. When you set a field on a Cap’n Proto object, you’re not populating some intermediate structure that later needs to be serialized - you’re writing bytes directly into a buffer that is already arranged exactly as it will appear on the network. Sending a message is writing that memory block to a socket. Receiving one is memory-mapping the incoming bytes and reading through pointers.
No parsing. No allocation. No transformation. The data never changes shape.
This works because Cap’n Proto arranges data exactly the way a C compiler would lay out a struct in memory - fixed-width fields at rigid byte offsets with proper alignment boundaries. When your CPU reads a UInt64 field from a Cap’n Proto buffer, it’s performing a single aligned memory load directly from the buffer into a register. No interpretation. No vtable lookup. Just arithmetic: base address + known offset = value.
Integers are stored little-endian, matching what x86 and modern ARM processors use natively. So there’s not even a byte-swap instruction between the buffer and the register. The CPU reads the value as-is.
The pointer problem (and its elegant solution)
Fixed-width fields are straightforward, but what about strings, lists, and nested objects? Those are variable-length. You can’t give them a fixed byte offset without wasting enormous amounts of space.
Cap’n Proto solves this with position-independent pointers. Instead of storing an absolute memory address like 0x7FFF0000 (which would be meaningless on another machine or in a different virtual address space), each pointer stores a relative offset - how many 8-byte words ahead the target data sits, measured from the pointer’s own position.
Think of it like giving directions as “200 meters north of where you’re standing” instead of “at latitude -33.8688, longitude 151.2093.” The relative instruction works no matter where you are.
This single design choice unlocks powerful capabilities:
- Memory-mapped files: You can
mmapa Cap’n Proto file from disk and traverse it immediately - the OS pages in data on demand, and your application never parses anything - Zero-copy IPC: Two processes on the same machine can share a memory region (via a ring buffer, for instance) and read/write Cap’n Proto messages concurrently with zero data copying and zero pointer fixups
- Trivial persistence: Writing a Cap’n Proto message to disk for later use is just a
write()syscall on the buffer. Reading it back is just aread()ormmap(). No serialization logic on either side
The space trade-off (and the built-in fix)
This layout isn’t free. Fixed-width fields and alignment constraints introduce padding. A UInt64 occupies 8 bytes whether the value is 0 or 18446744073709551615. Unset optional fields sit in the buffer as zeroes, taking up space that Protobuf wouldn’t waste because Protobuf simply omits them from the wire.
Cap’n Proto addresses this with a specialized “packing” compression that runs as a final pass before transmission. The algorithm is beautifully simple: it scans for sequences of zero bytes and collapses them using a compact bitmap header per 8-byte word. Each word gets a single-byte prefix where each bit indicates whether the corresponding byte in the word is zero or non-zero. Zero bytes are stripped; non-zero bytes are kept verbatim.
Because the algorithm does exactly one thing and does it without branching or allocation, it’s fast enough that the overhead is negligible. Packed Cap’n Proto messages end up roughly comparable in wire size to Protobuf, and occasionally smaller - while retaining the ability to unpack back to a directly-readable memory layout on the other end.
What it looks like in practice
Cap’n Proto isn’t raw binary cowboy territory. It’s strongly typed with a schema language that drives code generation, similar in spirit to Protobuf’s .proto files but with some important differences:
# user.capnp
@0xdbb9ad1f14bf486a; # Unique file ID
struct Address {
street @0 :Text;
city @1 :Text;
}
struct User {
id @0 :UInt64;
username @1 :Text;
# Embedding structs works naturally
address @2 :Address;
status @3 :Status;
enum Status {
active @0;
inactive @1;
suspended @2;
}
}Those @0, @1, @2 annotations are field ordinals, and they’re doing more than labeling - they define the exact memory offset where each field lives in the binary layout. You can never reorder them. You can never reuse a retired number. New fields must always take the next sequential ordinal. This rigidity is the price you pay for something valuable: guaranteed backwards compatibility without any runtime negotiation. A reader built against schema version 1 can safely read a message from version 5 - it just ignores the fields it doesn’t recognize. And if a version-5 reader gets an old version-1 message, the missing fields default to their zero values.
The compiled schema generates two types per struct - a Builder for writing and a Reader for reading - that give you a clean, type-safe API over the raw memory:
// main.cpp snippet
#include "user.capnp.h"
#include <capnp/message.h>
#include <iostream>
void createAndReadUser() {
// 1. Build the message directly in a memory arena
::capnp::MallocMessageBuilder message;
User::Builder user = message.initRoot<User>();
user.setId(12345);
user.setUsername("SeniorDev_88");
// At this point, the 'message' object in memory IS the serialized data.
// There is no separate expensive "serialize()" step before sending.
// 2. Reading it back (receiving end)
// We get a Reader which just uses pointer arithmetic to access fields.
User::Reader userReader = user.asReader();
std::cout << "User ID (read via pointer): " << userReader.getId() << std::endl;
}Look carefully at what’s happening here. MallocMessageBuilder allocates a contiguous block of memory - the “arena.” When you call user.setId(12345), you’re not populating a C++ struct that later gets serialized. You’re writing the integer 12345 directly into the arena at the byte offset that the schema compiler calculated for the id field. The arena is the message. It’s wire-ready the moment you finish building it.
On the read side, user.asReader() doesn’t copy or deserialize anything. It returns a thin wrapper that computes the same offsets to read values in place. getId() is just *(base_ptr + offset) - a pointer dereference, not a parse operation.
Builders write bytes at known offsets. Readers read bytes at known offsets. That’s the entire trick, and it’s the reason Cap’n Proto can claim “zero” serialization time with a straight face.
Promise pipelining: fixing the round-trip tax
The serialization story alone makes Cap’n Proto interesting for data-heavy systems. But the RPC protocol is where the design gets genuinely clever - and where it solves a problem that has plagued distributed systems for decades.
The problem with sequential RPC
Imagine you’re building a document editor with a microservices backend. A user opens a shared document, and your client needs to:
- Call
getDocument(docId)to fetch the document object - Call
getPermissions(document.ownerId)to check what the current user can do
In any conventional RPC framework - gRPC, REST, GraphQL - this is two sequential network round trips. The client sends request 1, waits for the network to deliver the response, then uses that response to construct request 2 and waits again.
Over a local network, maybe that’s 2ms of dead air. Over a cross-region link (Sydney to Virginia, say), each round trip is 150ms. Chain five dependent calls and you’re staring at 750ms of latency where zero actual computation is happening. The server processed each call in microseconds - the time was entirely consumed by photons bouncing through fiber optic cables.
This is the reason every team eventually ends up designing bloated “god endpoints” that cram multiple operations into one request. Not because they want to violate the single-responsibility principle, but because the physics of network latency actively punish clean, decomposed API design.
The promise pipelining solution
Cap’n Proto’s RPC system introduces an idea called promise pipelining (Varda calls it “time travel,” which honestly isn’t far off). Here’s how it works:
Every RPC call returns a promise - a handle representing the eventual result. But unlike a standard future, this promise is callable. You can invoke methods directly on an unresolved promise, and Cap’n Proto will pipeline those calls into a single network batch.
Back to our document editor:
- Client calls
getDocument(docId)→ getsPromise<Document> - Client immediately calls
getPermissions(promise.ownerId)- without waiting for step 1 to resolve - Cap’n Proto batches both operations and sends them together
- The server executes
getDocument(), extracts theownerIdlocally, feeds it directly intogetPermissions(), and sends back the final result
One round trip instead of two. The server chained the operations internally, so the intermediate data never crossed the network at all.
The savings compound with depth. Consider evaluating ((5 * 2) + ((7 - 3) * 10)) / (6 - 4) where each arithmetic operation is a separate RPC call. A traditional framework needs to resolve the tree bottom-up: 6 sequential round trips. Cap’n Proto expresses the entire dependency graph in one shot and resolves it server-side in a single hop.
The architectural implication is profound: you can design APIs the way you’d design them if latency didn’t exist. Small, focused, composable functions - each doing one thing well - and the RPC framework collapses the call chains for you. No more god endpoints. No more “batch this for performance.” The protocol handles it.
A note on capability security
Cap’n Proto’s RPC also bakes in a capability-based security model inspired by the E programming language. The idea is elegant: holding a reference to a remote object is your authorization to call it. There are no separate auth tokens to pass, no ACL checks per endpoint. When a server passes you an object reference over a connection, the reference itself is the credential - and the runtime assigns it an ephemeral ID scoped to that specific connection, so third parties can’t forge or intercept it.
This eliminates an entire class of confused-deputy vulnerabilities and makes multi-service authorization surprisingly clean. It’s a deep topic that warrants its own article, but worth flagging here because it’s a differentiator most people overlook when evaluating Cap’n Proto purely on serialization speed.
The honest trade-off analysis
I think Cap’n Proto is one of the most elegant pieces of systems engineering I’ve encountered. But I’ve also learned - the hard way, more than once - that elegance in isolation is a terrible reason to adopt technology. Here’s where I’d hesitate.
Where Cap’n Proto genuinely excels
Raw throughput at scale. When you’re processing hundreds of thousands of messages per second, the cost of serialization stops being a footnote and starts being the bottleneck. High-frequency trading firms, financial exchanges, and real-time telemetry pipelines use zero-copy formats (Cap’n Proto, FlatBuffers, SBE) because they literally cannot afford the CPU overhead of encode/decode. At firms like IMC Trading and Optiver, market tick data flows between strategy engines via shared memory ring buffers with zero-copy serialization - any allocation or parse step would blow the latency budget.
Inter-process communication on the same machine. This is an underappreciated killer feature. Because Cap’n Proto messages are position-independent, two processes can share a memory region and read/write structured messages without copying a single byte. No kernel transition, no pipe overhead, no socket. If you’re building a sidecar architecture or co-located microservices that need to exchange data at extremely high frequency, this is hard to beat.
Accessing huge messages. With Protobuf, deserialization cost scales linearly with message size - you parse the whole thing whether you need one field or all of them. With Cap’n Proto, reading a single field from a 10MB message is the same cost as reading it from a 100-byte message. You’re computing an offset and dereferencing a pointer. This makes it ideal for scenarios where messages are large but access patterns are sparse.
Where it gets painful
The ecosystem is thin outside C++ and Rust. Let me be direct about this. The C++ and Rust implementations are battle-tested and production-grade. But Java support is community-maintained. Go support is incomplete. C# barely exists. If your organization runs polyglot microservices - which most do - you’re going to hit a wall where one team can’t use Cap’n Proto because their language binding isn’t mature enough.
Cloud infrastructure doesn’t speak Cap’n Proto. This is the big one. Cap’n Proto RPC uses custom TCP streams, not HTTP/2. That means no native compatibility with AWS Application Load Balancers, Kubernetes ingress controllers, Istio, Envoy, or any of the service mesh infrastructure that gRPC leverages for free. Deploying Cap’n Proto in a standard cloud-native environment means building custom networking plumbing that your platform team has to maintain forever. Atlassian documented that even migrating to gRPC required substantial infra changes for ALB support - Cap’n Proto’s custom protocol is a harder lift still.
Binary formats are opaque by default. When something goes wrong with a JSON API, you curl the endpoint and read the response with your eyes. Protobuf has protoc --decode. Cap’n Proto has capnp decode, but the tooling ecosystem is smaller and less integrated with standard observability stacks. During an incident at 3am, that friction matters more than you’d think.
The mental model is unfamiliar. Developers used to JSON.parse() or Protobuf’s generated classes need to internalize concepts like memory arenas, builders versus readers, and why you can’t casually mutate a field after building a message. It’s not hard - but it’s different enough that onboarding a new team member takes noticeably longer than handing them a .proto file.
So when should you actually reach for this?
For the vast majority of web services, internal APIs, and CRUD applications - stick with JSON or Protobuf/gRPC. Seriously. The ecosystem support, the tooling, the developer familiarity, the cloud-native integration - they outweigh a performance delta you will genuinely never notice at 500 requests per second.
But there’s a class of problems where Cap’n Proto isn’t just faster - it’s a fundamentally different way of thinking about data in motion:
- High-frequency data pipelines processing market data, sensor telemetry, or event streams at volumes where serialization overhead consumes a measurable percentage of your CPU budget
- Shared-memory IPC between co-located processes that need to exchange structured data without kernel transitions, socket overhead, or memory copies
- Latency-critical RPC chains in distributed systems where promise pipelining can collapse what would be 5 sequential round trips into 1
- Memory-mapped data stores where you want to
mmapmulti-gigabyte structured datasets from disk and traverse them without loading the entire file into memory
The real takeaway here isn’t that Cap’n Proto is “better” than Protobuf. It’s that serialization isn’t a single, solved problem - it’s a design space with real trade-offs at every point. JSON optimizes for human readability and universality. Protobuf optimizes for compactness and broad language support. Cap’n Proto optimizes for raw speed and zero-copy access, at the cost of ecosystem breadth and infrastructure compatibility.
Knowing where each sits in that space, and being able to articulate why you’d choose one over another for a given set of constraints - that’s the kind of decision-making that separates writing code from engineering systems.
Further Reading
- Cap’n Proto Official Documentation
- Cap’n Proto RPC Protocol Specification
- Kenton Varda’s Cap’n Proto, FlatBuffers, and SBE Comparison
- Cloudflare’s Use of Cap’n Proto in the Workers Runtime
Have questions about zero-copy serialization or experience running Cap’n Proto in production? I’d love to hear about your use case - reach out!