8 Rust Serialization Crates That Cut Latency and Reduce Memory in High-Performance Services
Explore 8 Rust serialization crates—from bincode to CBOR—to cut latency, reduce memory, and boost performance. Find the right format for your use case.
I remember my first real Rust project—a small network service that needed to move thousands of messages per second. I started with JSON, because everyone knows JSON. The service worked, but latency was terrible. Every message had to be parsed into a DOM tree, then decoded into my struct. The CPU graph looked like a heart attack. That’s when I started learning about serialization formats and the trade-offs they carry. This article walks through eight Rust crates that helped me cut latency, reduce memory, and sometimes even avoid parsing altogether. Each one solves a different problem, and I’ll show you the code and the thinking behind it.
serde + bincode for simple binary speed
The first trick I learned was that you don’t need a human-readable format inside your own system. JSON is great for debugging, but for internal communication you just want bytes to move as fast as possible. The serde framework is Rust’s standard for serialization, and bincode is the fastest binary format that plugs right into it.
When I say “binary speed,” I mean you get a compact representation without overhead. You derive Serialize and Deserialize on your struct, call bincode::serialize, and you get a Vec<u8>. That’s it.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct MyPoint {
x: f64,
y: f64,
}
fn main() {
let point = MyPoint { x: 1.0, y: 2.0 };
let bytes = bincode::serialize(&point).unwrap();
let decoded: MyPoint = bincode::deserialize(&bytes).unwrap();
assert_eq!(point.x, decoded.x);
}
The decode step is almost as cheap as a memcpy. I used this combination for a real‑time game server and the latency dropped by an order of magnitude. The catch is that bincode is not self‑describing: the reader needs to know the exact schema. Also, if you ever change the struct layout, old bytes become garbage. So use it only when you control both ends.
serde_json when you need to talk to the outside world
JSON isn’t going anywhere. APIs, config files, logs—everyone speaks JSON. The serde_json crate is the standard choice for Rust. It’s fast enough for most web services, and it integrates perfectly with serde.
What I like about serde_json is that you can work with both strongly typed structs and the generic Value type. When you don’t know the exact shape of the data, use serde_json::Value.
use serde_json::{json, Value};
fn main() {
let data = json!({ "name": "Alice", "age": 30 });
let serialized = serde_json::to_string(&data).unwrap();
println!("{}", serialized);
let parsed: Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed["name"], "Alice");
}
For large JSON files, use serde_json::from_reader instead of loading the whole string into memory. I once processed a 2‑gigabyte JSON dump by reading it from a buffered file handle—worked fine, memory stayed low. Just be careful with deeply nested structures; parser recursion can overflow the stack. Use serde_json::Deserializer::from_reader with #[serde(deny_unknown_fields)] for strict validation.
rkyv zero‑copy deserialization for performance
The biggest performance win I ever got was with zero‑copy deserialization. Instead of parsing bytes into a new struct, you directly read fields from the raw buffer. The rkyv crate does exactly that: it serializes data into a format that can be memory‑mapped and used without any allocation or transformation.
Imagine you have a large static dataset—like a game’s asset database or a lookup table. With rkyv, you serialize once, write the bytes to a file, and later memory‑map that file. Every field access is just a pointer dereference.
use rkyv::{Archive, Deserialize, Serialize};
#[derive(Archive, Deserialize, Serialize, Debug)]
struct Asset {
id: u32,
name: String,
vertices: Vec<f32>,
}
fn main() {
let asset = Asset { id: 1, name: "cube".into(), vertices: vec![0.0; 30] };
let bytes = rkyv::to_bytes::<_, 256>(&asset).unwrap();
// later, mmap or direct slice access
let archived = rkyv::check_archived_root::<Asset>(&bytes).unwrap();
println!("First vertex: {}", archived.vertices[0]);
}
The check_archived_root function validates the structure’s alignment and bounds. It’s safe, but there’s an unsafe archived_root if you trust your data. The trade‑off is that the serialized format is architecture‑dependent: it assumes the same endianness and pointer size. Use rkyv when you control the platform or when you can store the data in a compatible way.
capnp (Cap’n Proto) for cross‑language schemas
Sometimes you need to speak the same format as a service written in C++, Python, or Go. Cap’n Proto is a schema‑driven binary format that supports versioning and optional fields. Like rkyv, messages can be read without decoding, but Cap’n Proto includes a rich schema language and code generation for many languages.
The setup is a bit heavier: you write a .capnp file, run the capnp compiler to generate Rust code, and then use that code in your project.
struct Point {
x @0 :Float64;
y @1 :Float64;
}
use capnp::serialize;
fn main() {
let mut message = capnp::message::Builder::new_default();
let mut point = message.init_root::<point_capnp::point::Builder>();
point.set_x(1.0);
point.set_y(2.0);
let mut buf = Vec::new();
serialize::write_message(&mut buf, &message).unwrap();
// read back
let reader = serialize::read_message(&mut &buf[..],
capnp::message::ReaderOptions::new()).unwrap();
let point_reader = reader.get_root::<point_capnp::point::Reader>().unwrap();
println!("x: {}", point_reader.get_x());
}
The builder and reader patterns are more verbose than serde, but you get robust forward/backward compatibility. If the schema evolves—say you add a z coordinate—old messages still parse fine, and you access the new field only if it’s present. I used Cap’n Proto for a distributed tracing system where different teams owned different parts of the pipeline. It saved hours of hand‑coded compatibility logic.
flatbuffers for on‑disk access without parsing
FlatBuffers is another zero‑copy format, but with a different design philosophy: it’s optimized for reading data directly from a buffer without any intermediate staging. The schema is compiled into Rust code, and you access fields by offset. This is incredibly useful for mobile apps or embedded systems where every allocation hurts.
// Generated from a .fbs schema
fn main() {
let mut builder = flatbuffers::FlatBufferBuilder::new();
let name = builder.create_string("Bob");
let person = my_schema::Person::create(&mut builder, &my_schema::PersonArgs {
name: Some(name),
age: 25,
});
builder.finish(person, None);
let buf = builder.finished_data();
// later, without any deserialization:
let person = flatbuffers::root::<my_schema::Person>(buf).unwrap();
println!("Name: {}", person.name().unwrap());
}
Notice that after builder.finish, you get a slice of bytes. You can write that to a file, memory‑map it later, and instantly read fields. No allocation, no parsing. The cost is that the schema must be known in advance, and the binary format is not as compact as some alternatives (it includes vtables for polymorphic access). I used FlatBuffers for loading level geometry in a game editor. Editing a saved level had zero startup delay because the data was already in memory from the file mapping.
prost for Protocol Buffers and gRPC
Protocol Buffers are the standard for many microservice architectures, especially when paired with gRPC. The prost crate generates efficient Rust code from .proto files. It’s well tested and integrates with tonic for streaming RPCs.
The workflow is similar to Cap’n Proto: write a .proto file, build with a build.rs script, then use the generated types.
syntax = "proto3";
message Item {
int32 id = 1;
string label = 2;
}
use my_protos::Item;
fn main() {
let item = Item { id: 1, label: "test".to_string() };
let encoded = prost::encode(&item).unwrap();
let decoded: Item = prost::decode(&encoded).unwrap();
assert_eq!(item.label, decoded.label);
}
Prost generates structs directly, so you don’t need to derive Serialize from serde. The encoding is compact and deterministic. I once migrated a Java gRPC service to Rust using prost and tonic; the wire format remained identical, and the Rust side performed about 2x faster in terms of throughput. The main downside is that the generated code can be large, and runtime reflection (for dynamic messages) is not straightforward.
msgpack for packed JSON
MessagePack is a binary super‑set of JSON. It keeps the same types (strings, numbers, arrays, maps) but encodes them in a compact binary form. Use rmp_serde to bridge it with serde.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct LogEvent {
timestamp: u64,
level: String,
message: String,
}
fn main() {
let event = LogEvent { timestamp: 1700000000, level: "info".into(), message: "hello".into() };
let bytes = rmp_serde::to_vec(&event).unwrap();
let decoded: LogEvent = rmp_serde::from_slice(&bytes).unwrap();
println!("{}: {}", decoded.level, decoded.message);
}
MessagePack is a great middle ground. It’s smaller than JSON, faster to parse (no string scanning for numbers), yet still self‑describing. I used it in a logging pipeline where we needed to store structured logs efficiently but still wanted to inspect them with a simple viewer. The rmp_serde crate works almost identically to serde_json, so switching is a one‑line change. The downside is that the wire format is not as widely supported as JSON or Protobuf, but for internal tools it’s hard to beat.
ciborium for CBOR in constrained devices
CBOR (Concise Binary Object Representation) is an IETF standard (RFC 7049) designed for very small code size and minimal message size. It’s used in IoT, COSE (CBOR Object Signing and Encryption), and WebAuthn. The ciborium crate provides a pure‑Rust implementation with no dependencies.
use ciborium::{into_writer, from_reader};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct SensorReading {
temperature: f32,
humidity: u8,
}
fn main() {
let reading = SensorReading { temperature: 23.5, humidity: 60 };
let mut buf = Vec::new();
into_writer(&reading, &mut buf).unwrap();
let decoded: SensorReading = from_reader(&buf[..]).unwrap();
assert_eq!(reading.temperature, decoded.temperature);
}
CBOR is particularly good when you’re sending data over low‑bandwidth links (like LoRaWAN) or when the parser must run on a microcontroller. The ciborium crate is no‑std compatible, so you can use it in embedded environments. I used it for a home automation sensor that reports temperature and humidity every minute; the messages are under 10 bytes. The format is extensible with tags, so you can embed your own application‑specific types. Just be aware that the library performs runtime allocation for complex values—if you need zero‑alloc, you might want to write a custom parser, but for most use cases it’s fine.
Picking the Right Tool
Choosing a serialization library comes down to three questions: who controls the schema, how fast do you need to read the data, and how much memory can you spend on the parsing process.
- If both ends are in Rust and you need raw speed, bincode or rkyv are your friends. Bincode is simpler; rkyv gives you zero‑copy access.
- If you need to talk to other languages, Cap’n Proto, FlatBuffers, or Protocol Buffers are the safe choice. Cap’n Proto and FlatBuffers also offer zero‑copy reads.
- If you need self‑describing but compact, MessagePack or CBOR fill the gap between JSON and binary schemas. CBOR has the edge for constrained devices.
- If you’re already using gRPC, prost is the natural fit.
I’ve personally found that the biggest mistake is over‑estimating the importance of speed. For a web API that handles a few thousand requests per second, JSON is fine. Optimize only when you measure a real bottleneck. When you do need to squeeze out every microsecond, reach for one of the zero‑copy formats.
Start with serde and a simple binary format. As your system grows, you’ll see where the serialization overhead actually hurts. Then swap in the specialized crate for that hot path. Good serialization is invisible—the data just moves. Let the crates handle the bytes while you focus on the logic.