We build network services in Rust because we want them to be fast. We write async code using Tokio, configure thread pools, tune TCP sockets, and expect to handle millions of requests per second. But performance isn't the only metric; developers also prioritize reliability, often choosing tools they trust over the latest shiny alternatives. Then we look at the profiler. Half the CPU time is spent in memory allocation and deallocation. We are allocating memory on the heap just to throw it away a microsecond later.
This overhead usually happens during deserialization. When you read a payload from a socket—such as when verifying API idempotency keys in a distributed system—parse it, and convert it into a domain model, you copy bytes. Every string field in your JSON, Protobuf, or message payload becomes a heap-allocated String.
There is a better way. Zero-copy deserialization allows you to point your structs directly to the raw buffer you just read from the network. Instead of copying bytes to new heap locations, you reuse the memory that is already there.
Combining this with async Rust is tricky. The borrow checker complains when lifetimes cross async boundaries, and resolving these issues requires a solid understanding of how Rust manages memory over time.
The Allocation Tax on the Hot Path
When you parse a standard payload using Serde, you typically define a struct with owned types:
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
username: String,
email: String,
bio: String,
}When you call serde_json::from_slice(&buffer), the parser allocates memory on the heap for the username, email, and bio. It copies the bytes from your buffer into these new allocations.
If you process ten thousand requests per second, you are triggering thirty thousand allocations per second just for this one struct. The allocator has to find free memory blocks, update its internal tracking structures, and clean them up when the struct goes out of scope. This causes memory fragmentation and keeps the CPU busy running allocator code instead of processing business logic. Under heavy load, this allocation overhead can lead to latency spikes and cascading failures, which is why implementing a circuit breaker pattern is essential for keeping microservices resilient.
To avoid this, we can use borrowed types:
#[derive(Deserialize)]
struct User<'a> {
username: &'a str,
email: &'a str,
bio: &'a str,
}Now, User<'a> contains only references. The username field is just a pointer to a location inside your raw buffer, along with a length. No heap allocations occur. Deserialization becomes an exercise in finding delimiters and validating UTF-8 strings.
This works perfectly in synchronous code. But when you move this struct into an async task, you run into a wall.
The Async Collision
In a typical async network server, you read data from a socket in a loop and spawn tasks to process each request:
use tokio::net::TcpListener;
use tokio::io::AsyncReadExt;
async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (mut socket, _) = listener.accept().await?;
tokio::spawn(async move {
let mut buffer = vec![0; 1024];
let n = socket.read(&mut buffer).await.unwrap();
let data = &buffer[..n];
// Try to deserialize using zero-copy
let user: User = serde_json::from_slice(data).unwrap();
// Process the user
process_user(user).await;
});
}
}
async fn process_user(user: User<'_>) {
// Business logic here
}This code compiles because the lifetime of user is bound to the buffer inside the spawned task. But what happens if you want to read the buffer in one place, parse it, and then spawn a separate task to process the parsed data?
tokio::spawn(async move {
let mut buffer = vec![0; 1024];
let n = socket.read(&mut buffer).await.unwrap();
let data = &buffer[..n];
let user: User = serde_json::from_slice(data).unwrap();
// We want to spawn a new task to handle processing
tokio::spawn(async move {
save_to_db(user).await;
});
});The compiler rejects this code immediately. It complains that user does not live long enough.
The function tokio::spawn requires the future it receives to have a 'static lifetime. This is because the Tokio runtime executes tasks on a multi-threaded thread pool. The runtime cannot know when a task will finish, or if the thread running it will be swapped out. It must guarantee that all data inside the task remains valid for the entire duration of the task's life.
If User<'a> borrows from a buffer that lives on the stack of the parent task, that buffer will be destroyed when the parent task finishes. If the spawned child task runs after the parent task dies, it would try to read from deallocated memory. Rust prevents this use-after-free bug at compile time.
We need a way to keep the buffer alive as long as the deserialized struct exists, while still avoiding individual allocations for each field.
Managing Buffer Ownership with Bytes
The bytes crate provides a solution to this problem. It offers a Bytes type, which is a cheap-to-clone, reference-counted wrapper around a byte array.
When you clone a Bytes struct, you do not copy the underlying data. You only increment an atomic reference counter. You can slice Bytes to create smaller Bytes instances that point to specific offsets within the original memory block.
use bytes::Bytes;
let original = Bytes::from(vec![1, 2, 3, 4, 5]);
let slice = original.slice(1..4); // Points to [2, 3, 4]Both original and slice share the same memory allocation. The allocation stays alive until all Bytes instances pointing to it are dropped.
We can define our message structure using Bytes instead of references or owned strings:
struct UserBytes {
username: Bytes,
email: Bytes,
bio: Bytes,
}This struct is 'static because it owns the Bytes containers. We can spawn tasks with it, move it across threads, and store it in multitenant databases.
However, writing a parser that yields Bytes instead of &str is difficult with standard Serde. Serde is designed to work with Rust's native reference types. If you want to use Serde's zero-copy features directly, you need a way to bundle the buffer and the borrowed struct together.
Self-Referential Structs and Yoke
To use User<'a> with its borrowed references, we must store the backing buffer and the struct in the same memory envelope. This is a self-referential structure. The struct has a field that points to another field inside the same struct.
Safe Rust does not allow self-referential structs because moving the struct in memory would invalidate the internal pointers. If you move the struct, the buffer moves, but the reference fields still point to the old memory location.
The yoke crate solves this problem. It provides a wrapper called Yoke that allows you to attach a lifetime-bound structure to the owner of its backing data.
Here is how you use yoke to achieve zero-copy deserialization in an async task:
use yoke::{Yoke, Yokeable};
use serde::Deserialize;
#[derive(Deserialize, Yokeable)]
struct User<'a> {
#[serde(borrow)]
username: &'a str,
#[serde(borrow)]
email: &'a str,
}
fn parse_user(buffer: Vec<u8>) -> Result<Yoke<User<'static>, Vec<u8>>, serde_json::Error> {
Yoke::try_attach_to_cart(buffer, |bytes| {
serde_json::from_slice::<User>(bytes)
})
}The Yoke type signature looks strange. It takes User<'static> as its first parameter, even though User has references. The Yokeable derive macro allows Yoke to safely project the lifetime of the struct to match the lifetime of the backing "cart" (the Vec<u8>).
You can pass this Yoke struct to tokio::spawn because it is 'static. The backing buffer is moved into the Yoke container, and the reference-based User struct is stored alongside it. When you need to access the fields, you borrow from the yoke:
let yoked_user = parse_user(raw_bytes).unwrap();
tokio::spawn(async move {
let user = yoked_user.get();
println!("Username: {}", user.username);
});This approach allows you to use standard Serde deserialization while keeping your data structures compatible with async executors.
Binary Formats and Direct Memory Mapping
If you control both the client and the server, you do not have to use JSON or other text-based formats. You can use binary serialization frameworks designed specifically for zero-copy operations.
The rkyv crate takes a different path than Serde. Instead of parsing a byte stream into a struct, rkyv structures the serialized data so that it matches the in-memory layout of the target type.
To read the data, you do not perform any parsing. You cast the byte slice directly to the archived version of your type.
use rkyv::{Archive, Serialize, Deserialize};
#[derive(Archive, Serialize, Deserialize)]
#[archive(compare(PartialEq))]
#[archive_attr(derive(Debug))]
struct User {
username: String,
email: String,
}When you serialize this struct, rkyv writes the bytes in a specific layout. When you want to read it, you access the archived type:
let buffer: Vec<u8> = serialize_user_to_bytes();
// No allocations, no parsing
let archived = rkyv::access_archived::<User>(&buffer);
assert_eq!(archived.username, "alice");The archived variable is a reference to an ArchivedUser type. This type uses ArchivedString under the hood, which reads directly from the byte buffer without allocating heap memory.
To use this in an async context, you combine rkyv with bytes::Bytes. You read the network data into a Bytes buffer, send the buffer to the async task, and access the archived data inside the task.
use bytes::Bytes;
use tokio::net::TcpStream;
async fn handle_connection(mut stream: TcpStream) {
let mut buffer = vec![0; 512];
let n = stream.read(&mut buffer).await.unwrap();
buffer.truncate(n);
let shared_buffer = Bytes::from(buffer);
tokio::spawn(async move {
// The task owns shared_buffer, so this is safe and 'static
let archived = rkyv::access_archived::<User>(&shared_buffer);
process_archived_user(archived).await;
});
}This design eliminates the CPU cost of deserialization. The CPU only needs to validate that the buffer layout is correct.
The Hidden Costs of Zero-Copy
Zero-copy deserialization is not a free performance upgrade. It introduces architectural trade-offs that you must evaluate.
The most significant risk is memory retention. If you deserialize a small field from a large buffer and keep that field alive, the entire buffer must remain in memory.
Imagine you receive a 10MB JSON payload containing a list of users. You parse it using a zero-copy struct and extract a single username:
struct User<'a> {
username: &'a str,
}If you store this User struct in an in-memory cache for several hours, you are not just caching the username. You are keeping the entire 10MB raw buffer in memory because the username field holds a reference to it. If you do this for thousands of users, your application will quickly run out of memory.
If you need to keep parsed data around for a long time, it is better to copy it to an owned structure. Use zero-copy only for short-lived operations, like reading a request, processing it, and sending a response.
Another issue is memory alignment. Binary formats like rkyv require the byte buffer to be aligned to specific boundaries in memory. If you read bytes from a network socket into a vector, the vector might not be aligned correctly for the types you want to cast it to.
If the buffer is misaligned, attempting to read the archived type will result in a runtime error or a panic. You must copy the bytes to an aligned buffer before reading them, which defeats the purpose of zero-copy.
Finally, you must consider security when dealing with untrusted inputs. If you cast raw bytes directly to Rust types without validation, a malicious payload can exploit memory layout assumptions and cause undefined behavior.
Always use the validation APIs provided by libraries like rkyv or flatbuffers when parsing data from the public internet. Validation has a CPU cost, but it is necessary to prevent crashes and memory safety violations.
Choosing the Right Pattern
If you are building an async Rust service, use this guide to choose your deserialization strategy:
Use standard owned deserialization (String, Vec) if your payloads are small, your throughput is moderate, or you need to store the parsed data in memory for a long time. The simplicity of owned types is worth the minor allocation overhead.
Use yoke with Serde if you are parsing JSON or MessagePack over network connections, you need high throughput, and the parsed structures are processed quickly and then dropped.
Use binary formats like rkyv or flatbuffers combined with bytes::Bytes if you control both sides of the network, require the lowest possible latency, and want to avoid parsing overhead entirely.



