← Back to DevBytes

Implementing Cap'n Proto: From Theory to Practice

Understanding Cap'n Proto: The Theory Behind Zero-Copy Serialization

Cap'n Proto is a data interchange format and RPC protocol that prioritizes speed through an extreme approach: it eliminates serialization and deserialization entirely at the data level. Unlike Protocol Buffers, FlatBuffers, or JSON, Cap'n Proto arranges data in memory exactly as it will be transmitted over the wire. This means the wire format is the memory format. When you receive a Cap'n Proto message, you can access its contents immediately without unpacking or allocating a separate data structure.

The core insight comes from how modern CPUs access memory. When you perform pointer dereferences and structure accesses, the CPU already does zero-copy traversal of in-memory layouts. Cap'n Proto simply standardizes that layout so it works across process boundaries, network links, and even different machines. The result is that reading a field from a Cap'n Proto message is not fundamentally slower than reading a field from a plain C struct — it is just a pointer offset calculation followed by a memory read.

The system consists of three layers: the data encoding (the binary format for structs, lists, and primitives), the schema language (a rich type system for defining your data structures and RPC interfaces), and the RPC protocol (a full-featured remote procedure call framework built on the same zero-copy principles). Each layer builds on the one below it, and you can use just the serialization format without touching RPC if your use case only needs data persistence or message passing.

Why Cap'n Proto Matters: The Practical Advantages

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Traditional serialization frameworks force you to pay a computational tax: you allocate a message, fill it with data, serialize it to bytes, then on the receiving end you parse those bytes back into allocated objects. This parse-allocate cycle burns CPU cycles and generates memory fragmentation. Cap'n Proto sidesteps this entirely. The benefits cascade through your system architecture:

Getting Started: Installation and Your First Schema

Before writing code, you need the Cap'n Proto compiler. On most systems, you can install it via your package manager or build from source:

# macOS
brew install capnp

# Ubuntu/Debian
sudo apt-get install capnproto

# Verify installation
capnp --version

If you need to build from source, clone the repository and use the standard CMake workflow. The compiler is the only required tool; language-specific runtime libraries are separate. For C++, you'll typically depend on the capnp and kj libraries (kj is the underlying C++ utility library that provides arena allocation, async I/O, and other primitives).

Create your first schema file. Let's model a simple address book entry:

# addressbook.capnp
@0xabcdef0123456789;  # Unique schema identifier

struct Person {
  id @0 : UInt32;
  name @1 : Text;
  email @2 : Text;
  phones @3 : List(PhoneNumber);
  
  struct PhoneNumber {
    number @0 : Text;
    type @1 : Type;
    
    enum Type {
      mobile @0;
      home @1;
      work @2;
    }
  }
}

struct AddressBook {
  people @0 : List(Person);
}

The @0xabcdef0123456789 is a globally unique 64-bit identifier for this schema file. Every schema file must have one. When you evolve your schema, you keep this ID and increment a version number. The @0, @1, @2 annotations on fields are ordinal numbers that define the field's position in the wire format. They must never change once assigned. If you want to rename a field, keep its ordinal the same. If you want to remove a field, mark it as deprecated or simply stop writing to it — but never reuse its ordinal for a different field.

Schema Language in Depth: Types, Unions, and Interfaces

The Cap'n Proto schema language is expressive enough to model complex domain data while remaining simple to learn. Beyond basic structs and lists, it offers several powerful constructs that deserve detailed attention.

Built-in Types

The primitive types are exactly what you'd expect, with a few notable additions:

# Primitive types
Bool                    # true or false
Int8, Int16, Int32, Int64   # Signed integers
UInt8, UInt16, UInt32, UInt64  # Unsigned integers
Float32, Float64         # IEEE floating point
Text                     # UTF-8 string (always valid UTF-8)
Data                     # Arbitrary binary blob
Void                     # Zero bits, used in unions
AnyPointer               # Untyped pointer, for dynamic data

The Text type is guaranteed valid UTF-8; the compiler and runtime enforce this. Data is raw bytes with no encoding constraints. AnyPointer lets you store values whose types aren't known until runtime — it's the escape hatch for truly dynamic data, but it bypasses schema validation, so use it sparingly.

Lists, Maps, and Generics

struct Inventory {
  items @0 : List(Item);
  metadata @1 : Map(Text, Text);      # Key-value pairs
  taggedItems @2 : List(Item) @Tagged; # Tagged union list
}

# Generic structs
struct Pair(T, U) {
  first @0 : T;
  second @1 : U;
}

# Usage: Pair(Text, Int32) creates a typed pair

Lists in Cap'n Proto are not pointer arrays — they are contiguous blocks of elements, just like C arrays. A List(Int32) is a contiguous sequence of 32-bit integers on the wire and in memory. Lists of structs store the structs inline, one after another. This contiguous layout makes iteration extremely cache-friendly. Map(Text, Text) is syntactic sugar for a list of key-value pair structs with a specific layout; the compiler generates convenience methods for lookups.

Unions for Polymorphism

struct Shape {
  union {
    circle @0 : Circle;
    rectangle @1 : Rectangle;
    polygon @2 : Polygon;
    empty @3 : Void;     # Explicit "none" variant
  }
  color @4 : Color;       # Fields after union are allowed
}

struct Circle {
  radius @0 : Float64;
}

Unions are discriminant-based: exactly one variant is active at a time. Fields placed before the union share memory with the union discriminant, but fields after the union (like color above) are stored separately and accessible regardless of which variant is active. This is a clean pattern for tagged data: put the discriminator and variant-specific data in the union, and put common fields after it.

Interfaces for RPC

interface FileSystem {
  open @0 (path : Text) -> (handle : FileHandle);
  read @1 (handle : FileHandle, offset : Int64, size : Int32) -> (data : Data);
  write @2 (handle : FileHandle, offset : Int64, data : Data) -> ();
  close @3 (handle : FileHandle) -> ();
}

interface FileHandle {
  # FileHandle is a capability — a remote object reference
  read @0 (offset : Int64, size : Int32) -> (data : Data);
  seek @1 (position : Int64) -> ();
  getSize @2 () -> (size : Int64);
}

Interfaces define methods with named parameters and return types. The arrow -> separates input parameters from return values. Methods can have zero return values (indicated by empty parentheses after the arrow). When you call open on a FileSystem, the returned FileHandle is not just data — it's a live capability that lets you invoke methods on a remote object. This is the foundation of Cap'n Proto's RPC model.

Compiling Schemas and Code Generation

The capnp compiler translates your schema file into language-specific source code. For C++, the command is straightforward:

# Compile schema to C++ header and source
capnp compile -oc++ addressbook.capnp

# Output files:
# addressbook.capnp.h   (header)
# addressbook.capnp.c++ (source)

The -o flag specifies the output language. Available backends include c++, python, rust, java, c-sharp, go, and several others contributed by the community. The generated C++ code includes type-safe builder and reader classes for every struct, along with RPC stubs and servers if your schema defines interfaces.

In larger projects, you'll want to integrate schema compilation into your build system. For CMake-based C++ projects:

# CMakeLists.txt snippet
find_package(CapnProto REQUIRED)

capnp_generate_cpp(
  SCHEMA_SOURCES
  SCHEMA_HEADERS
  schemas/addressbook.capnp
  schemas/filesystem.capnp
)

add_executable(my_app
  main.cpp
  ${SCHEMA_SOURCES}
)
target_link_libraries(my_app
  PRIVATE
  capnp::capnp
  capnp::capnp-rpc
  capnp::kj
)

The capnp_generate_cpp CMake function (provided by the Cap'n Proto CMake config) automatically runs the compiler and adds the generated files to your build. The generated headers are placed in your build directory; include them with the .capnp.h extension.

Serialization and Deserialization: Building and Reading Messages

Now we move from theory to practice. This section demonstrates the complete lifecycle: allocating a message, populating it, and reading it back — all without a serialize/parse step.

Building a Message

Cap'n Proto messages live in arena-allocated buffers. The capnp::MallocMessageBuilder manages a heap-allocated arena, growing it as needed. For embedded or performance-critical contexts, you can use capnp::FlatArrayMessageReader on pre-allocated memory or mmap'd files.

#include "addressbook.capnp.h"
#include <iostream>
#include <fstream>

void build_address_book() {
  // Create a message builder with a heap arena
  capnp::MallocMessageBuilder message;
  
  // Get the root object as an AddressBook builder
  // The root is always a struct, never a list or primitive
  AddressBook::Builder addressBook = message.initRoot();
  
  // Get the people list (initially empty, need to allocate)
  auto people = addressBook.initPeople(2);  // Pre-allocate for 2 entries
  
  // First person
  Person::Builder person1 = people[0];
  person1.setId(1001);
  person1.setName("Alice Johnson");
  person1.setEmail("alice@example.com");
  
  // Build phone list for person1
  auto phones1 = person1.initPhones(1);
  phones1[0].setNumber("+1-555-0101");
  phones1[0].setType(Person::PhoneNumber::Type::MOBILE);
  
  // Second person
  Person::Builder person2 = people[1];
  person2.setId(1002);
  person2.setName("Bob Smith");
  person2.setEmail("bob@example.com");
  
  auto phones2 = person2.initPhones(2);
  phones2[0].setNumber("+1-555-0200");
  phones2[0].setType(Person::PhoneNumber::Type::HOME);
  phones2[1].setNumber("+1-555-0201");
  phones2[1].setType(Person::PhoneNumber::Type::WORK);
  
  // Serialize to bytes (this is just a memcpy of the arena)
  // Write to a file or send over a socket
  auto flatMessage = capnp::messageToFlatArray(message);
  
  std::ofstream out("addressbook.bin", std::ios::binary);
  out.write(
    reinterpret_cast(flatMessage.asBytes().begin()),
    flatMessage.asBytes().size()
  );
  out.close();
  
  std::cout << "Wrote address book with " << people.size() << " entries\n";
}

Notice what's absent: no SerializeToString() or SerializeToArray() call that does work. The messageToFlatArray function simply copies the arena's contiguous memory region into a flat buffer. The builder's in-memory layout is the wire format. This is the fundamental difference from Protocol Buffers or JSON libraries where serialization involves walking the object graph and encoding it.

Reading a Message

Reading is equally direct. The FlatArrayMessageReader takes a pointer to a contiguous byte buffer and lets you access it as typed data immediately:

void read_address_book(const std::string& filename) {
  // Read file into memory
  std::ifstream in(filename, std::ios::binary | std::ios::ate);
  size_t size = in.tellg();
  in.seekg(0, std::ios::beg);
  
  std::vector buffer(size);
  in.read(buffer.data(), size);
  in.close();
  
  // Create a reader that points directly at the buffer
  // No parsing, no allocations — the buffer IS the data structure
  capnp::FlatArrayMessageReader reader(
    kj::arrayPtr(reinterpret_cast(buffer.data()),
                 buffer.size() / sizeof(capnp::word))
  );
  
  // Get the root AddressBook reader
  AddressBook::Reader addressBook = reader.getRoot();
  
  // Iterate people — each access is just pointer arithmetic
  for (Person::Reader person : addressBook.getPeople()) {
    std::cout << "Person ID: " << person.getId() << "\n";
    std::cout << "Name: " << person.getName().cStr() << "\n";
    std::cout << "Email: " << person.getEmail().cStr() << "\n";
    
    for (auto phone : person.getPhones()) {
      std::cout << "  Phone: " << phone.getNumber().cStr() << "\n";
      switch (phone.getType()) {
        case Person::PhoneNumber::Type::MOBILE:
          std::cout << "    (mobile)\n";
          break;
        case Person::PhoneNumber::Type::HOME:
          std::cout << "    (home)\n";
          break;
        case Person::PhoneNumber::Type::WORK:
          std::cout << "    (work)\n";
          break;
      }
    }
    std::cout << "---\n";
  }
}

The critical detail: reader.getRoot() does not parse the buffer. It casts the memory region to the expected layout, validates pointer bounds, and returns a reader that interprets offsets relative to the buffer start. Field access like person.getName() follows a pointer stored at a known offset in the struct, reads the string length, and returns a view of the bytes — still no allocation. The .cStr() call gives you a const char* pointing directly into the buffer.

Working with Nested Structures and AnyPointer

Sometimes you need to handle data whose exact type isn't known at schema definition time. AnyPointer fields let you store arbitrary Cap'n Proto objects and interrogate them at runtime:

void dynamic_field_example() {
  capnp::MallocMessageBuilder message;
  auto root = message.initRoot(message.getRoot());
  
  // Store an integer
  root.get("value").setInt32(42);
  
  // Store a text string
  root.get("label").setText("dynamic content");
  
  // Store a nested struct (you can build it inline)
  auto nested = root.get("nested").initStruct();
  nested.get("x").setFloat64(3.14159);
  
  // Read back dynamically
  capnp::FlatArrayMessageReader reader(
    capnp::messageToFlatArray(message).asPtr()
  );
  
  auto dynamicRoot = reader.getRoot(
    reader.getRoot().getAs(capnp::Schema::from())
  );
  
  // Check what's inside
  for (auto field : dynamicRoot.getSchema().getFields()) {
    std::cout << "Field: " << field.getProto().getName() << "\n";
  }
}

Dynamic access loses type safety and some performance, but it's essential for generic tools like schema inspectors, protocol bridges, or data migration utilities that operate on messages without compile-time knowledge of their schemas.

Implementing RPC with Cap'n Proto

The RPC framework is where Cap'n Proto truly distinguishes itself from other serialization systems. It's not just a message format with some RPC bolted on — it's a carefully designed distributed object system with capabilities, promise pipelining, and automatic flow control.

The RPC Model: Capabilities and Promises

In Cap'n Proto RPC, an interface is a capability — a permission to call methods on a remote object. When you obtain an interface reference (say, from a method return), you're not getting data about the object; you're getting a live connection to it. The runtime manages the transport, keeping track of outstanding calls and their responses. Promise pipelining means you can chain calls without waiting for intermediate results:

# Schema snippet
interface FileServer {
  getRoot @0 () -> (root : Directory);
}

interface Directory {
  listFiles @0 () -> (files : List(FileInfo));
  open @1 (name : Text) -> (file : File);
}

interface File {
  read @0 (offset : Int64, size : Int32) -> (data : Data);
  getSize @1 () -> (size : Int64);
}

With promise pipelining, you can call getRoot() and, in the same client code block, immediately call open("config.txt") on the result — before the getRoot response has even arrived. The RPC runtime sends both calls together and wires the result of the first directly into the argument of the second on the server side. This eliminates a full network round-trip per sequential step.

Building an RPC Server

Let's implement a complete file server. The server side requires implementing the interface methods and setting up the RPC infrastructure:

#include "filesystem.capnp.h"
#include <capnp/ez-rpc.h>
#include <iostream>
#include <fstream>
#include <filesystem>

// Implement the File interface
class FileImpl final : public File::Server {
public:
  kj::Promise read(ReadContext context) override {
    auto params = context.getParams();
    int64_t offset = params.getOffset();
    int32_t size = params.getSize();
    
    // In a real server, you'd read from an actual file handle
    // Here we simulate with a stored buffer
    auto result = context.getResults();
    std::string content = "File contents from offset " + std::to_string(offset);
    result.setData(kj::heapString(content.substr(0, std::min(size, (int32_t)content.length()))));
    
    return kj::READY_NOW;  // Synchronous completion
  }
  
  kj::Promise getSize(GetSizeContext context) override {
    context.getResults().setSize(1024);  // Example fixed size
    return kj::READY_NOW;
  }
};

// Implement the Directory interface
class DirectoryImpl final : public Directory::Server {
private:
  std::filesystem::path basePath_;
  
public:
  DirectoryImpl(std::filesystem::path basePath) : basePath_(std::move(basePath)) {}
  
  kj::Promise listFiles(ListFilesContext context) override {
    auto results = context.getResults();
    auto fileList = results.initFiles(0);  // Start empty
    
    // Enumerate actual filesystem
    try {
      for (const auto& entry : std::filesystem::directory_iterator(basePath_)) {
        if (entry.is_regular_file()) {
          auto fileInfo = fileList.add();
          fileInfo.setName(entry.path().filename().string());
          fileInfo.setSize(entry.file_size());
        }
      }
    } catch (const std::filesystem::filesystem_error& e) {
      context.getResults().setError(kj::str("Directory listing failed: ", e.what()));
    }
    
    return kj::READY_NOW;
  }
  
  kj::Promise open(OpenContext context) override {
    auto params = context.getParams();
    std::string name = params.getName();
    
    // Create a new File capability and return it
    // The client can immediately call read() on this capability
    context.getResults().setFile(kj::heap());
    
    return kj::READY_NOW;
  }
};

// Implement the FileServer interface
class FileServerImpl final : public FileServer::Server {
private:
  std::filesystem::path rootPath_;
  
public:
  FileServerImpl(std::filesystem::path rootPath) : rootPath_(std::move(rootPath)) {}
  
  kj::Promise getRoot(GetRootContext context) override {
    context.getResults().setRoot(kj::heap(rootPath_));
    return kj::READY_NOW;
  }
};

int main(int argc, char* argv[]) {
  if (argc != 2) {
    std::cerr << "Usage: " << argv[0] << " 
\n"; std::cerr << "Example: " << argv[0] << " unix:/tmp/fileserver.sock\n"; return 1; } // ez-rpc handles the event loop, listening, and capability export capnp::EzRpcServer server( kj::heap(std::filesystem::current_path()), argv[1] ); std::cout << "File server listening on " << argv[1] << "\n"; // Wait forever (event loop runs in background threads) kj::NEVER_DONE.wait(server.getWaitScope()); return 0; }

The server implementation pattern is consistent: inherit from the generated Server class for each interface, override each method's handler, and fill in the results builder. The kj::heap<...>() calls create reference-counted objects that the RPC runtime manages. When you return a capability from a method (like setFile(kj::heap<FileImpl>())), the runtime automatically wires it so the client can call methods on it.

Building an RPC Client

The client side connects to the server, obtains capabilities, and makes calls — potentially with promise pipelining:

#include "filesystem.capnp.h"
#include <capnp/ez-rpc.h>
#include <iostream>

int main(int argc, char* argv[]) {
  if (argc != 2) {
    std::cerr << "Usage: " << argv[0] << " 
\n"; return 1; } // Connect to server capnp::EzRpcClient client(argv[1]); auto& waitScope = client.getWaitScope(); // Get the FileServer capability FileServer::Client fileServer = client.getMain(); // Call getRoot() — this returns immediately with a pipelined promise auto rootPromise = fileServer.getRootRequest().send(); // Pipeline: call open() on the root result BEFORE getRoot() completes auto filePromise = rootPromise.getRoot().openRequest(); filePromise.setName("example.txt"); auto fileOpenResult = filePromise.send(); // Pipeline again: call getSize() on the file BEFORE open() completes auto sizePromise = fileOpenResult.getFile().getSizeRequest().send(); // Now wait for the final result — all intermediate calls were sent together auto sizeResult = sizePromise.wait(waitScope); std::cout << "File size: " << sizeResult.getSize() << "\n"; // Demonstrate listing files auto listPromise = fileServer.getRootRequest().send() .getRoot().listFilesRequest().send() .wait(waitScope); for (auto fileInfo : listPromise.getFiles()) { std::cout << "File: " << fileInfo.getName().cStr() << " (" << fileInfo.getSize() << " bytes)\n"; } return 0; }

The promise pipelining is visible in the chained calls. rootPromise.getRoot().openRequest() doesn't block waiting for getRoot() to finish — it creates a pending RPC call whose input depends on a future result. The runtime sends getRoot() and open("example.txt") together, and the server processes them as a pipeline. Only at sizePromise.wait(waitScope) do we block until the final answer arrives. This pattern turns sequential RPC chains into single-round-trip operations.

Handling Errors in RPC

Cap'n Proto RPC integrates error handling through KJ's exception system. Methods can fail, and failures propagate as exceptions on the client side:

// Server side: throwing an exception
kj::Promise open(OpenContext context) override {
  auto params = context.getParams();
  std::string name = params.getName();
  
  if (name.find("..") != std::string::npos) {
    // Reject path traversal attempts
    KJ_FAIL_REQUIRE("Path traversal detected in filename");
  }
  
  if (!std::filesystem::exists(basePath_ / name)) {
    KJ_FAIL_REQUIRE("File not found: " + name);
  }
  
  context.getResults().setFile(kj::heap(basePath_ / name));
  return kj::READY_NOW;
}

// Client side: catching errors
try {
  auto file = fileServer.getRootRequest().send()
      .getRoot().openRequest().setName("nonexistent.txt").send()
      .wait(waitScope);
} catch (const kj::Exception& e) {
  std::cerr << "RPC error: " << e.getDescription().cStr() << "\n";
  // Handle gracefully — the capability is invalid now
}

KJ exceptions carry rich metadata including the failure reason, a stack trace (in debug builds), and optionally a custom error code. They integrate with the event loop so that a failed promise automatically propagates the error through all dependent promises.

Best Practices for Production Cap'n Proto Systems

Schema Design Principles

Assign ordinals deliberately. Never change a field's ordinal once your schema is deployed. If you need to rename a field, keep its ordinal. If you need to remove a field, either leave a gap (marked with a comment) or use the reserved keyword. Reusing an ordinal for a different field will corrupt data for anyone using an older schema version.

# Evolving a schema correctly
struct UserProfile {
  username @0 : Text;
  email @1 : Text;
  # Field @2 was "legacyField" — removed in v2, do not reuse
  displayName @3 : Text;        # New field, new ordinal
  avatarUrl @4 : Text;
  joinedAt @5 : Int64;          # Unix timestamp, added in v3
}

Prefer struct fields over AnyPointer. AnyPointer bypasses type checking and makes your data opaque to tools. Reserve it for truly polymorphic containers where the type varies at runtime in ways your schema cannot express. Even then, consider using unions with a finite set of known variants first.

Use unions for mutually exclusive data. If exactly one of several fields will be set at a time, use a union. This saves memory (the fields share storage) and makes the exclusivity explicit in the type system. The compiler enforces that only one variant is active.

Keep structs focused and shallow. Deeply nested structures with many levels of inline structs can create large messages that are expensive to traverse. While Cap'n Proto handles nesting efficiently, designing flat, focused structs improves cache locality and makes your schema easier to understand.

Performance Optimization

Use arena allocation strategically. The MallocMessageBuilder uses a doubling strategy for arena growth. If you know the approximate message size in advance, allocate a capnp::FlatArrayMessageBuilder with a pre-sized buffer to avoid reallocations:

// Pre-allocate a 4KB buffer
capnp::FlatArrayMessageBuilder builder(4096);
auto root = builder.initRoot();
// Fill in fields — no reallocations if it fits in 4KB

Reuse message builders. If you're sending many messages in a loop (e.g., a game server sending state updates), reuse a single MallocMessageBuilder and reset it between uses. This recycles the arena memory and avoids repeated allocation overhead:

capnp::MallocMessageBuilder reusableMessage;

while (serverRunning) {
  // Reset to empty state (reuses existing arena memory)
  reusableMessage.reset();
  
  auto root = reusableMessage.initRoot();
  // Populate with current state...
  
  sendToClients(capnp::messageToFlatArray(reusableMessage));
}

Read messages without copying when possible. If you receive data into a buffer that persists (e.g., a ring buffer in a network library), create a FlatArrayMessageReader that points directly at the received bytes without copying them into a separate arena. For mmap'd files, point the reader at the mapped region and traverse the entire file as Cap'n Proto structures with zero copy overhead.

RPC Architecture Patterns

Design for promise pipelining. When designing RPC interfaces, think about call chains. If a client typically calls getFoo() and then immediately calls a method on the returned Foo, structure your interfaces so the client can pipeline the second call without waiting. Avoid designs that force the client to process intermediate results before knowing what to call next.

Manage capability lifetimes explicitly. Capabilities (interface references) consume resources on both client and server. When you're done with a capability, release it. The RPC runtime handles garbage collection of unreferenced capabilities, but explicit release gives predictable cleanup:

// Explicitly release a capability
auto file = directory.openRequest().setName("temp.txt").send().wait(waitScope).getFile();
// ... use the file ...
file = nullptr;  // Release the capability, server can clean up

Use streaming for large data transfers. For multi-gigabyte file transfers or continuous data streams, Cap'n Proto's RPC supports streaming methods (declared with stream keyword in the schema). Streaming lets you send data in chunks with backpressure, rather than allocating a single enormous message:

# Schema with streaming
interface FileTransfer {
  upload @0 (metadata : FileMetadata) -> (stream : Data) -> (checksum : Data);
  # Client sends metadata, then streams data chunks, finally server returns checksum
}

Testing and

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles