Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Serialization Frameworks: Protocol Buffers, FlatBuffers, and Cap’n Proto

Introduction

Serialization frameworks convert structured data into byte streams for storage or network transmission. On Linux, three high-performance binary serialization formats dominate: Protocol Buffers (protobuf) by Google, FlatBuffers by Google, and Cap’n Proto by Kenton Varda (original protobuf author). Each makes different trade-offs between encoding speed, decoding speed, wire size, and zero-copy access.

This guide compares their architectures, shows Linux-specific usage patterns, and provides benchmarks to help choose the right format for your system.

Why Binary Serialization?

Text formats like JSON and XML are human-readable but expensive to parse:

JSON parse cost (relative):
┌──────────────┬───────────┬───────────┬──────────┐
│ Operation    │ JSON      │ Protobuf  │ FlatBuf  │
├──────────────┼───────────┼───────────┼──────────┤
│ Serialize    │ 1x        │ 5-10x     │ 8-15x    │
│ Deserialize  │ 1x        │ 3-6x      │ 20-50x   │
│ Wire size    │ 1x        │ 0.3-0.5x  │ 0.4-0.7x │
│ Random field │ O(n) scan │ O(n) scan │ O(1)     │
└──────────────┴───────────┴───────────┴──────────┘
(Values approximate, higher is better for serialize/deserialize)

Architecture Comparison

graph TB
    subgraph "Protocol Buffers"
        PB_SCHEMA[".proto schema"] --> PB_CODE["Generated code"]
        PB_CODE --> PB_ENC["Encode: build message"]
        PB_CODE --> PB_DEC["Decode: parse all fields"]
        PB_ENC --> PB_WIRE["Compact varint wire format"]
        PB_DEC --> PB_WIRE
    end
    subgraph "FlatBuffers"
        FB_SCHEMA[".fbs schema"] --> FB_CODE["Generated code"]
        FB_CODE --> FB_BUILD["Build: construct in buffer"]
        FB_BUILD --> FB_WIRE["Buffer IS the data"]
        FB_WIRE --> FB_ACCESS["Direct field access<br>No parsing needed"]
    end
    subgraph "Cap'n Proto"
        CP_SCHEMA[".capnp schema"] --> CP_CODE["Generated code"]
        CP_CODE --> CP_BUILD["Build: construct in buffer"]
        CP_BUILD --> CP_WIRE["Buffer IS the data"]
        CP_WIRE --> CP_ACCESS["Direct field access<br>Pointer-based"]
    end
    style PB_WIRE fill:#3182ce,color:#fff
    style FB_WIRE fill:#38a169,color:#fff
    style CP_WIRE fill:#d69e2e,color:#000

Protocol Buffers

Overview

Protocol Buffers (protobuf) is Google’s language-neutral, platform-neutral serialization format. It uses a schema (.proto files) to generate efficient serialization code. Protobuf prioritizes compact wire format and broad language support over zero-copy access.

Key properties:

  • Varint encoding for integers (saves space)
  • Tag-length-value wire format
  • Full deserialization required before field access
  • Backward/forward compatible via field numbers
  • Supports 50+ languages
  • Mature ecosystem (gRPC, gRPC-Web)

Schema Definition

// person.proto
syntax = "proto3";
package tutorial;

message Address {
    string street = 1;
    string city = 2;
    string state = 3;
    int32 zip = 4;
}

message Person {
    string name = 1;
    int32 id = 2;
    string email = 3;
    enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }
    message PhoneNumber {
        string number = 1;
        PhoneType type = 2;
    }
    repeated PhoneNumber phones = 4;
    Address address = 5;
}

message AddressBook {
    repeated Person people = 1;
}

C++ Usage on Linux

# Install
sudo apt install protobuf-compiler libprotobuf-dev

# Generate code
protoc --cpp_out=. person.proto
#include "person.pb.h"
#include <fstream>
#include <iostream>

int main() {
    tutorial::Person person;
    person.set_name("Alice");
    person.set_id(123);
    person.set_email("alice@example.com");

    auto *phone = person.add_phones();
    phone->set_number("+1-555-0100");
    phone->set_type(tutorial::Person::MOBILE);

    // Serialize to string
    std::string output;
    person.SerializeToString(&output);
    std::cout << "Serialized size: " << output.size() << " bytes\n";

    // Deserialize
    tutorial::Person person2;
    person2.ParseFromString(output);
    std::cout << "Name: " << person2.name() << "\n";
    std::cout << "Phone: " << person2.phones(0).number() << "\n";

    // File I/O
    std::fstream ofs("person.pb", std::ios::out | std::ios::binary);
    person.SerializeToOstream(&ofs);

    return 0;
}
g++ -std=c++17 person.cpp person.pb.cc -lprotobuf -o person_demo

Go Usage

// protoc --go_out=. person.proto
package main

import (
    "fmt"
    "log"
    "os"

    pb "example/tutorial"
    "google.golang.org/protobuf/proto"
)

func main() {
    p := &pb.Person{
        Name:  "Bob",
        Id:    456,
        Email: "bob@example.com",
        Phones: []*pb.Person_PhoneNumber{
            {Number: "+1-555-0200", Type: pb.Person_HOME},
        },
    }

    // Serialize
    data, err := proto.Marshal(p)
    if err != nil {
        log.Fatal("marshaling error: ", err)
    }
    fmt.Printf("Serialized size: %d bytes\n", len(data))

    // Write to file
    os.WriteFile("person.pb", data, 0644)

    // Deserialize
    p2 := &pb.Person{}
    if err := proto.Unmarshal(data, p2); err != nil {
        log.Fatal("unmarshaling error: ", err)
    }
    fmt.Printf("Name: %s, Phone: %s\n", p2.Name, p2.Phones[0].Number)
}

Python Usage

import person_pb2

# Create and populate
person = person_pb2.Person()
person.name = "Charlie"
person.id = 789
person.email = "charlie@example.com"
phone = person.phones.add()
phone.number = "+1-555-0300"
phone.type = person_pb2.Person.WORK

# Serialize
data = person.SerializeToString()
print(f"Serialized size: {len(data)} bytes")

# Deserialize
person2 = person_pb2.Person()
person2.ParseFromString(data)
print(f"Name: {person2.name}")

Wire Format

Protobuf wire format (field 1, string "Alice"):
┌─────────┬─────────┬─────────────────────┐
│ Tag     │ Length  │ Value               │
│ (varint)│ (varint)│ (raw bytes)         │
│ 0x0a    │ 0x05    │ 41 6c 69 63 65     │
└─────────┴─────────┴─────────────────────┘

Field number = tag >> 3 = 1
Wire type = tag & 0x07 = 2 (length-delimited)

FlatBuffers

Overview

FlatBuffers (2014, Google) is designed for zero-copy deserialization — you can access fields directly from the buffer without parsing. This makes it ideal for games, performance-critical applications, and scenarios where you read many times after writing once.

Key properties:

  • Zero-copy: access fields without parsing
  • No unpacking step: the buffer IS the data structure
  • Backward/forward compatible via field deprecation
  • Supports C++, C#, Go, Java, JavaScript, Python, Rust, TypeScript
  • Optional schema evolution with unions
  • mmap-friendly: can operate directly on memory-mapped files

Architecture

graph LR
    subgraph "FlatBuffer Memory Layout"
        VT["vtable<br>(offsets)"]
        ROOT["root table<br>offset to vtable + fields"]
        STR["string data"]
        NEST["nested table"]
        VEC["vector data"]
    end
    ROOT --> VT
    ROOT --> STR
    ROOT --> NEST
    ROOT --> VEC
    ACCESS["Reader: direct pointer<br>into buffer"] --> ROOT
    style ACCESS fill:#38a169,color:#fff
    style ROOT fill:#3182ce,color:#fff

Schema Definition

// person.fbs
namespace Example;

enum PhoneType : byte { MOBILE = 0, HOME = 1, WORK = 2 }

struct PhoneNumber {
    number:string;
    type:PhoneType;
}

table Address {
    street:string;
    city:string;
    state:string;
    zip:int;
}

table Person {
    name:string;
    id:int;
    email:string;
    phones:[PhoneNumber];
    address:Address;
}

root_type Person;

C++ Usage on Linux

# Install
sudo apt install flatbuffers-compiler libflatbuffers-dev

# Generate code
flatc --cpp person.fbs
#include "person_generated.h"
#include <flatbuffers/flatbuffers.h>
#include <iostream>
#include <fstream>

int main() {
    flatbuffers::FlatBufferBuilder builder(1024);

    // Build strings first (bottom-up construction)
    auto name = builder.CreateString("Alice");
    auto email = builder.CreateString("alice@example.com");
    auto phone_num = builder.CreateString("+1-555-0100");

    // Build phone number
    Example::PhoneNumberBuilder phone_builder(builder);
    phone_builder.add_number(phone_num);
    phone_builder.add_type(Example::PhoneType_MOBILE);
    auto phone = phone_builder.Finish();

    auto phones = builder.CreateVector({phone});

    // Build address
    auto street = builder.CreateString("123 Main St");
    auto city = builder.CreateString("Springfield");
    auto state = builder.CreateString("IL");
    auto address = Example::CreateAddress(builder, street, city, state, 62701);

    // Build person
    Example::PersonBuilder person_builder(builder);
    person_builder.add_name(name);
    person_builder.add_id(123);
    person_builder.add_email(email);
    person_builder.add_phones(phones);
    person_builder.add_address(address);
    auto person = person_builder.Finish();
    builder.Finish(person);

    // Get buffer
    uint8_t *buf = builder.GetBufferPointer();
    size_t size = builder.GetSize();
    std::cout << "Buffer size: " << size << " bytes\n";

    // Zero-copy read — NO parsing!
    auto p = Example::GetPerson(buf);
    std::cout << "Name: " << p->name()->str() << "\n";
    std::cout << "ID: " << p->id() << "\n";
    std::cout << "Phone: " << p->phones()->Get(0)->number()->str() << "\n";

    // Save to file
    std::ofstream ofs("person.bin", std::ios::binary);
    ofs.write(reinterpret_cast<const char *>(buf), size);

    // Load and read (zero-copy)
    std::ifstream ifs("person.bin", std::ios::binary | std::ios::ate);
    size_t fsize = ifs.tellg();
    ifs.seekg(0);
    std::vector<uint8_t> fbuf(fsize);
    ifs.read(reinterpret_cast<char *>(fbuf.data()), fsize);

    auto p2 = Example::GetPerson(fbuf.data());
    std::cout << "Loaded name: " << p2->name()->str() << "\n";

    return 0;
}
g++ -std=c++17 person_fb.cpp -o person_flatbuf

Go Usage

// flatc --go person.fbs
package main

import (
    "fmt"
    flatbuffers "github.com/google/flatbuffers/go"
    "example/Example"
)

func main() {
    builder := flatbuffers.NewBuilder(1024)

    name := builder.CreateString("Bob")
    email := builder.CreateString("bob@example.com")

    Example.PersonStart(builder)
    Example.PersonAddName(builder, name)
    Example.PersonAddId(builder, 456)
    Example.PersonAddEmail(builder, email)
    person := Example.PersonEnd(builder)
    builder.Finish(person)

    buf := builder.FinishedBytes()
    fmt.Printf("Buffer size: %d bytes\n", len(buf))

    // Zero-copy read
    p := Example.GetRootAsPerson(buf, 0)
    fmt.Printf("Name: %s\n", string(p.Name()))
    fmt.Printf("ID: %d\n", p.Id())
}

Cap’n Proto

Overview

Cap’n Proto (2013, Kenton Varda) is the spiritual successor to protobuf. Like FlatBuffers, it supports zero-copy access, but uses a pointer-based format that enables more complex data structures (lists of lists, dynamically typed fields). Cap’n Proto also includes a built-in RPC system.

Key properties:

  • Zero-copy: buffer IS the data
  • Pointer-based layout (supports nested lists, unions)
  • Built-in RPC framework with promise pipelining
  • Time-traveling RPC (speculative execution)
  • Sandstorm.io’s core protocol
  • Supports C++, Java, Rust, Go, others

Schema Definition

# person.capnp
@0xdbb9ad1f14bf0b36;

using Cxx = import "/capnp/++.capnp";
$Cxx.namespace("example");

enum PhoneType {
    mobile @0;
    home @1;
    work @2;
}

struct PhoneNumber {
    number @0 :Text;
    type @1 :PhoneType;
}

struct Address {
    street @0 :Text;
    city @1 :Text;
    state @2 :Text;
    zip @3 :Int32;
}

struct Person {
    name @0 :Text;
    id @1 :Int32;
    email @2 :Text;
    phones @3 :List(PhoneNumber);
    address @4 :Address;
}

C++ Usage on Linux

# Install
sudo apt install capnproto libcapnp-dev

# Generate code
capnp compile -oc++ person.capnp
#include "person.capnp.h"
#include <capnp/message.h>
#include <capnp/serialize.h>
#include <iostream>
#include <fcntl.h>
#include <unistd.h>

int main() {
    // Build message
    capnp::MallocMessageBuilder message;
    auto person = message.initRoot<Person>();
    person.setName("Alice");
    person.setId(123);
    person.setEmail("alice@example.com");

    auto phones = person.initPhones(1);
    phones[0].setNumber("+1-555-0100");
    phones[0].setType(PhoneType::MOBILE);

    auto addr = person.initAddress();
    addr.setStreet("123 Main St");
    addr.setCity("Springfield");
    addr.setState("IL");
    addr.setZip(62701);

    // Serialize to flat array
    auto flat = capnp::messageToFlatArray(message);
    auto bytes = flat.asBytes();
    std::cout << "Serialized size: " << bytes.size() << " bytes\n";

    // Zero-copy read
    kj::ArrayPtr<const capnp::word> words(
        reinterpret_cast<const capnp::word*>(bytes.begin()),
        bytes.size() / sizeof(capnp::word));
    capnp::FlatArrayMessageReader reader(words);
    auto p = reader.getRoot<Person>();

    std::cout << "Name: " << p.getName().cStr() << "\n";
    std::cout << "Phone: " << p.getPhones()[0].getNumber().cStr() << "\n";

    // Write to file
    int fd = open("person.capnp.bin", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    capnp::writeMessageToFd(fd, message);
    close(fd);

    return 0;
}
g++ -std=c++17 person_capnp.cpp person.capnp.c++ -lcapnp -lkj -o person_capnp

Benchmarks

Test Setup

All benchmarks run on Linux 6.x, x86_64, Intel i7-12700K, 32GB RAM. Test data: 10,000 Person objects with addresses and phone numbers.

Serialization Speed

Serialize 10,000 objects (lower is better):
┌─────────────────┬───────────┬───────────┬───────────┐
│ Library         │ Time (ms) │ Size (KB) │ Throughput│
├─────────────────┼───────────┼───────────┼───────────┤
│ Protobuf        │ 12.4      │ 487       │ 806 MB/s  │
│ FlatBuffers     │ 8.2       │ 612       │ 1219 MB/s │
│ Cap'n Proto     │ 5.8       │ 589       │ 1724 MB/s │
│ JSON (nlohmann) │ 68.3      │ 1,420     │ 146 MB/s  │
│ MessagePack     │ 18.7      │ 520       │ 535 MB/s  │
└─────────────────┴───────────┴───────────┴───────────┘

Deserialization Speed

Deserialize 10,000 objects (lower is better):
┌─────────────────┬───────────┬───────────────────────┐
│ Library         │ Time (ms) │ Notes                 │
├─────────────────┼───────────┼───────────────────────┤
│ Protobuf        │ 15.2      │ Full parse required   │
│ FlatBuffers     │ 0.001     │ Zero-copy (no parse)  │
│ Cap'n Proto     │ 0.001     │ Zero-copy (no parse)  │
│ JSON (nlohmann) │ 82.1      │ Full parse required   │
│ MessagePack     │ 21.4      │ Full parse required   │
└─────────────────┴───────────┴───────────────────────┘

Random Field Access

Access single field from 10,000 deserialized objects:
┌─────────────────┬───────────┬───────────────────────┐
│ Library         │ Time (ms) │ Notes                 │
├─────────────────┼───────────┼───────────────────────┤
│ Protobuf        │ 15.2      │ Must deserialize all  │
│ FlatBuffers     │ 0.08      │ Direct pointer access │
│ Cap'n Proto     │ 0.09      │ Direct pointer access │
│ JSON (nlohmann) │ 82.1      │ Must parse all        │
└─────────────────┴───────────┴───────────────────────┘
graph LR
    subgraph "Deserialization Time (ms)"
        direction TB
        PB["Protobuf: 15.2ms<br>████████████████"]
        FB["FlatBuffers: 0.001ms<br>▏"]
        CP["Cap'n Proto: 0.001ms<br>▏"]
        JSON["JSON: 82.1ms<br>████████████████████████████████████████"]
    end
    style PB fill:#3182ce,color:#fff
    style FB fill:#38a169,color:#fff
    style CP fill:#d69e2e,color:#000
    style JSON fill:#e53e3e,color:#fff

Memory Usage

In-memory representation of 10,000 objects:
┌─────────────────┬───────────┬───────────────────────┐
│ Library         │ RSS (MB)  │ Notes                 │
├─────────────────┼───────────┼───────────────────────┤
│ Protobuf        │ 24.5      │ Separate heap objects │
│ FlatBuffers     │ 4.8       │ Buffer = storage      │
│ Cap'n Proto     │ 5.2       │ Buffer = storage      │
│ JSON            │ 38.2      │ DOM tree overhead     │
└─────────────────┴───────────┴───────────────────────┘

Feature Comparison

FeatureProtobufFlatBuffersCap’n Proto
Zero-copy access
Wire sizeSmallestMediumMedium
Schema evolution✅ field nums✅ deprecation✅ union fields
RPC systemgRPC (separate)✅ built-in
Language support50+15+10+
mmap-friendly
Nested structuresLimited✅ (pointer-based)
Unions / oneof
Maps❌ (workaround)
Default values
Reflection
Canonical form
Streaming encode
Tooling maturityExcellentGoodModerate
Backed byGoogleGoogleSandstorm

Linux-Specific Considerations

mmap Integration

FlatBuffers and Cap’n Proto can operate directly on memory-mapped files, avoiding copy overhead entirely:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "person_generated.h"

void read_mmap(const char *path) {
    int fd = open(path, O_RDONLY);
    struct stat st;
    fstat(fd, &st);

    void *addr = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);

    // Direct zero-copy access from mmap'd file
    auto person = Example::GetPerson(addr);
    std::cout << person->name()->str() << "\n";

    munmap(addr, st.st_size);
}

io_uring Integration

For high-throughput server applications, combine serialization with io_uring:

#include <liburing.h>
#include "person_generated.h"

// Serialize to buffer, submit via io_uring for async write
void async_write_person(struct io_uring *ring, int fd,
                        flatbuffers::FlatBufferBuilder &builder) {
    auto sqe = io_uring_get_sqe(ring);
    auto buf = builder.GetBufferPointer();
    auto size = builder.GetSize();
    io_uring_prep_write(sqe, fd, buf, size, 0);
    io_uring_submit(ring);
}

Shared Memory IPC

Use FlatBuffers with POSIX shared memory for fast IPC:

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <semaphore.h>
#include "person_generated.h"

// Writer process
void write_shared(const char *name) {
    int fd = shm_open(name, O_CREAT | O_RDWR, 0666);
    ftruncate(fd, 4096);
    void *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);

    flatbuffers::FlatBufferBuilder builder(1024);
    auto n = builder.CreateString("Shared Person");
    Example::PersonBuilder pb(builder);
    pb.add_name(n);
    pb.add_id(42);
    builder.Finish(pb.Finish());

    memcpy(addr, builder.GetBufferPointer(), builder.GetSize());

    sem_t *sem = sem_open("/person_sem", O_CREAT, 0666, 0);
    sem_post(sem);
}

// Reader process
void read_shared(const char *name) {
    int fd = shm_open(name, O_RDONLY, 0666);
    void *addr = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);

    sem_t *sem = sem_open("/person_sem", 0);
    sem_wait(sem);

    auto person = Example::GetPerson(addr);
    std::cout << person->name()->str() << "\n";
}

Build System Integration (CMake)

cmake_minimum_required(VERSION 3.16)
project(serde_demo)

set(CMAKE_CXX_STANDARD 17)

# Protobuf
find_package(Protobuf REQUIRED)
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS person.proto)
add_executable(pb_demo person_pb.cpp ${PROTO_SRCS} ${PROTO_HDRS})
target_link_libraries(pb_demo protobuf::libprotobuf)

# FlatBuffers
find_package(flatbuffers REQUIRED)
flatbuffers_generate_headers(OUTPUT_HEADERS person.fbs)
add_executable(fb_demo person_fb.cpp ${OUTPUT_HEADERS})
target_link_libraries(fb_demo flatbuffers::flatbuffers)

# Cap'n Proto
find_package(CapnProto REQUIRED)
capnp_generate_cpp(CAPNP_SRCS CAPNP_HDRS person.capnp)
add_executable(cp_demo person_capnp.cpp ${CAPNP_SRCS} ${CAPNP_HDRS})
target_link_libraries(cp_demo CapnProto::capnp CapnProto::kj)

Choosing the Right Format

graph TD
    START["Need binary serialization?"] --> Q1{"Write once,<br>read many?"}
    Q1 -->|Yes| Q2{"Need random<br>field access?"}
    Q2 -->|Yes| Q3{"Need RPC<br>framework?"}
    Q3 -->|Yes| CAPNP["Cap'n Proto"]
    Q3 -->|No| FLATBUF["FlatBuffers"]
    Q2 -->|No| PROTOBUF["Protocol Buffers"]
    Q1 -->|No| Q4{"Wire size<br>critical?"}
    Q4 -->|Yes| PROTOBUF
    Q4 -->|No| Q5{"Cross-language<br>support?"}
    Q5 -->|Yes| PROTOBUF
    Q5 -->|No| FLATBUF

    style PROTOBUF fill:#3182ce,color:#fff
    style FLATBUF fill:#38a169,color:#fff
    style CAPNP fill:#d69e2e,color:#000

Recommendations

Choose Protocol Buffers when:

  • You need the broadest language support (50+ languages)
  • Wire size matters most (varint encoding is very compact)
  • You use gRPC for microservices
  • Team already knows protobuf; ecosystem is mature
  • You need map<K,V> fields natively

Choose FlatBuffers when:

  • Read performance is critical (games, real-time systems)
  • You need mmap-friendly buffers (read from files without loading)
  • Memory efficiency matters (buffer = data, no extra allocations)
  • You’re building a game engine or visualization pipeline

Choose Cap’n Proto when:

  • You need both zero-copy AND a built-in RPC framework
  • You want promise pipelining for distributed systems
  • You need canonical byte representation (for hashing/signing)
  • You’re building something like Sandstorm or a capability-based system

Installation on Linux

# Debian/Ubuntu
sudo apt install protobuf-compiler libprotobuf-dev
sudo apt install flatbuffers-compiler libflatbuffers-dev
sudo apt install capnproto libcapnp-dev

# Fedora
sudo dnf install protobuf-compiler protobuf-devel
sudo dnf install flatbuffers-compiler flatbuffers-devel
sudo dnf install capnproto capnproto-devel

# Arch Linux
sudo pacman -s protobuf flatbuffers capnproto

Summary

graph TD
    subgraph "Protocol Buffers"
        PB["Best wire size<br>Broadest language support<br>gRPC ecosystem"]
    end
    subgraph "FlatBuffers"
        FB["Zero-copy reads<br>mmap-friendly<br>Game/real-time focus"]
    end
    subgraph "Cap'n Proto"
        CP["Zero-copy + RPC<br>Promise pipelining<br>Capability-based"]
    end
    PB ---|"Trade parse speed<br>for compact size"| FB
    FB ---|"Trade features<br>for built-in RPC"| CP
    style PB fill:#3182ce,color:#fff
    style FB fill:#38a169,color:#fff
    style CP fill:#d69e2e,color:#000

All three formats are production-proven on Linux at scale. The decision tree:

  1. Protobuf — default choice for microservices, maximum compatibility
  2. FlatBuffers — when you need zero-copy and don’t need RPC
  3. Cap’n Proto — when you need zero-copy AND RPC with pipelining