Skip to main content
  1. Languages/
  2. Rust Guides/

Zero-Copy Deserialization in Rust: Crushing Latency with Serde and rkyv

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the world of high-performance systems engineering, memory is the new disk. It’s 2025, and while our CPUs have become insanely fast, the cost of moving data around—allocating generic heap memory, copying bytes, and garbage collection (or in Rust’s case, dropping complex ownership trees)—remains the primary bottleneck for throughput.

If you are building a high-frequency trading engine, a real-time analytics pipeline, or a high-throughput game server, standard deserialization is likely eating up 30% to 50% of your CPU cycles. You take bytes from the network, allocate a String, copy the bytes into that String, and then hand it to your struct.

Zero-copy deserialization changes this paradigm. Instead of owning the data, your structs simply borrow references to the raw input buffer.

In this deep dive, we are going beyond the basics. We will explore:

  1. How to abuse generic lifetimes in Serde to achieve zero-copy JSON parsing.
  2. The limitations of text-based zero-copy.
  3. The “nuclear option”: Using rkyv for guaranteed zero-copy binary serialization.
  4. A performance showdown between the two approaches.

Prerequisites & Environment
#

To follow along with the code in this article, you will need a modern Rust environment. While the concepts apply to older versions, we assume you are running the latest stable Rust channel.

Environment Setup:

rustc --version
# Recommended: rustc 1.83+ (or latest 2025 stable)

Create a new project to run the benchmarks and examples:

cargo new zero_copy_demo
cd zero_copy_demo

Update your Cargo.toml with the heavy hitters we will be using. We need serde for the standard approach and rkyv for the total zero-copy approach.

[package]
name = "zero_copy_demo"
version = "0.1.0"
edition = "2024"

[dependencies]
# Serde ecosystem
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# The zero-copy champion
rkyv = { version = "0.7", features = ["validation"] }

# Utilities
anyhow = "1.0"
criterion = "0.5" # For benchmarking logic
bytes = "1.5"

Part 1: The Concept of Zero-Copy
#

Before writing code, let’s align on what “Zero-Copy” actually means in Rust.

In a traditional setup (Copy-heavy), deserialization looks like this:

  1. Read [u8] from a socket.
  2. Parser finds a string "hello".
  3. Allocator requests heap memory for a new String.
  4. Bytes are copied from the socket buffer to the new heap location.
  5. Old buffer is discarded.

In a Zero-Copy setup:

  1. Read [u8] from a socket.
  2. Parser finds a string "hello".
  3. The struct field is a &'a str pointing directly into the socket buffer.
  4. No allocation. No copy.

Here is a visual representation of the flow:

flowchart TD subgraph "Traditional Deserialization" A[Raw Input Buffer] -->|Parse| B(Identify Data) B -->|Malloc| C[Allocate Heap Memory] C -->|Memcpy| D[Copy Data to Heap] D -->|Own| E[Struct String Field] end subgraph "Zero-Copy Deserialization" F[Raw Input Buffer] -->|Parse/Map| G(Identify Data) G -->|Reference| H[Struct &str Field] F -.->|Lifetime 'a| H end style A fill:#f9f,stroke:#333,stroke-width:2px style F fill:#bbf,stroke:#333,stroke-width:2px style E fill:#f96,stroke:#333,stroke-width:2px style H fill:#9f6,stroke:#333,stroke-width:2px

Part 2: Zero-Copy with Serde (JSON)
#

Serde is the de-facto standard for Rust serialization. Most developers start with String fields. Let’s look at the “Naive” approach versus the “Zero-Copy” approach.

The Naive Approach (Owned Types)
#

This is what 90% of Rust codebases look like. It is safe, easy, and usually “fast enough”—until it isn’t.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct LogEntryOwned {
    timestamp: u64,
    level: String,      // Allocates!
    message: String,    // Allocates!
    service_id: String, // Allocates!
}

fn naive_parsing() {
    let json_data = r#"
    {
        "timestamp": 1735689600,
        "level": "ERROR",
        "message": "Connection timeout in subsystem A",
        "service_id": "auth-service-us-east-1"
    }"#;

    let entry: LogEntryOwned = serde_json::from_str(json_data).unwrap();
    println!("Owned: {:?}", entry);
}

Every time this runs, we perform three heap allocations. If you process 100,000 logs per second, you are thrashing the allocator.

The Optimized Approach (Borrowed Types)
#

To achieve zero-copy with Serde, we must use lifetimes. We tell the compiler: “This struct cannot outlive the raw JSON string it came from.”

We use &'a str instead of String. However, there is a catch: JSON strings often contain escape characters (e.g., \n, \"). If a string contains escape characters, Serde must allocate a new string to store the unescaped version.

To handle both cases (raw reference vs. allocated unescaped string), we use std::borrow::Cow (Copy on Write).

use serde::{Deserialize, Serialize};
use std::borrow::Cow;

// We introduce a lifetime 'a to tie the struct to the input data
#[derive(Serialize, Deserialize, Debug)]
struct LogEntryZeroCopy<'a> {
    timestamp: u64,

    // #[serde(borrow)] tells Serde to try to borrow from input first
    #[serde(borrow)]
    level: Cow<'a, str>,
    
    #[serde(borrow)]
    message: Cow<'a, str>,
    
    #[serde(borrow)]
    service_id: Cow<'a, str>,
}

fn optimized_parsing() {
    let json_data = r#"
    {
        "timestamp": 1735689600,
        "level": "ERROR",
        "message": "Connection timeout",
        "service_id": "auth-service"
    }"#;

    // Notice we deserialize from 'str', not a Reader
    let entry: LogEntryZeroCopy = serde_json::from_str(json_data).unwrap();
    
    // Proof of zero-copy:
    if let Cow::Borrowed(s) = entry.message {
        println!("Success! Message is borrowed: '{}'", s);
    } else {
        println!("Allocated :(");
    }
}

The Limitations of Serde Zero-Copy
#

While Cow<'a, str> reduces allocations significantly, Serde still has to parse the data. It has to iterate through the JSON, find brackets, validate UTF-8, and handle escape sequences.

CPU-wise, you are still paying for the parsing logic, even if you aren’t paying for the malloc.


Part 3: True Zero-Copy with rkyv
#

If you want absolute performance, you need to abandon text formats (JSON/XML) and even standard binary formats (Bincode/Protobuf) which often require a deserialization step to reconstruction objects.

Enter rkyv (pronounced “archive”).

rkyv guarantees total zero-copy deserialization. It works by structuring the serialized bytes in such a way that they represent the exact memory layout of the Rust struct. “Deserialization” becomes nothing more than a pointer cast.

Why rkyv is different
#

  1. No Parsing: The data is ready to use immediately.
  2. No Allocation: It reads directly from the byte buffer.
  3. Partial Access: You can read the last field of a 1GB file without reading the rest of the file.

Implementing rkyv
#

Let’s rebuild our Log Entry system using rkyv.

use rkyv::{Archive, Deserialize, Serialize};
use rkyv::ser::{serializers::AllocSerializer, Serializer};

// rkyv generates an "Archived" version of this struct automatically
// e.g., ArchivedLogEntry
#[derive(Archive, Deserialize, Serialize, Debug, PartialEq)]
#[archive(check_bytes)] // Enables validation for safety
struct LogEntryRkyv {
    timestamp: u64,
    level: String,      
    message: String,
    service_id: String,
}

fn rkyv_demonstration() {
    let original = LogEntryRkyv {
        timestamp: 1735689600,
        level: "ERROR".to_string(),
        message: "Critical failure in core".to_string(),
        service_id: "payment-gateway".to_string(),
    };

    // 1. Serialization (Write to bytes)
    // In a real app, you would do this once, or on the sending server
    let mut serializer = AllocSerializer::<256>::default();
    serializer.serialize_value(&original).unwrap();
    let bytes = serializer.into_serializer().into_inner();

    println!("Serialized size: {} bytes", bytes.len());

    // 2. Zero-Copy Access (Read from bytes)
    // We treat the 'bytes' vector as if it were a file mapped into memory
    
    // check_archived_root validates the buffer structure safely
    // It ensures we aren't reading garbage memory
    let archived = unsafe { rkyv::archived_root::<LogEntryRkyv>(&bytes) };

    // Access fields directly!
    // Note: 'archived.level' is not a String, it's an ArchivedString
    println!("Timestamp: {}", archived.timestamp);
    println!("Level: {}", archived.level);
    
    // No heap allocation happened here for the reading side!
}

fn main() {
    println!("--- Serde Demo ---");
    // Call your serde functions here (defined in previous blocks)
    
    println!("\n--- rkyv Demo ---");
    rkyv_demonstration();
}

Understanding the Magic
#

When you access archived.level, you aren’t accessing a standard Rust String. You are accessing an rkyv::string::ArchivedString. This type acts like a string (Deref<Target=str>) but it points to a relative offset within the bytes array.

This is extremely powerful for caching. You can mmap a huge dataset from disk and access it instantly with zero CPU overhead for parsing.


Part 4: Performance Comparison & Trade-offs
#

It is crucial to understand when to use which tool. Zero-copy isn’t a silver bullet; it usually trades CPU cycles for developer ergonomics or format flexibility.

Feature Comparison
#

Feature Serde (Owned) Serde (Zero-Copy Cow) rkyv
Format JSON, TOML, Bincode JSON, Bincode rkyv (proprietary)
Human Readable Yes Yes No
Parsing Cost High Medium Near Zero
Allocations High Low None
Schema Evolution Excellent Good Strict/Difficult
Use Case Public APIs, Configs High-throughput APIs Inter-process comms, Caching, Games

Performance Considerations
#

  1. JSON vs. Binary: If you are using JSON, the parsing overhead (lexing tokens) usually dwarfs the allocation overhead. Moving to Serde Zero-Copy (Cow) helps, but switching to a binary format helps more.
  2. Validation: rkyv is fast because it trusts the memory layout. However, if you receive data from an untrusted source, you must use check_archived_root (which validates the buffer). This takes some time, though it is still faster than full JSON parsing.
  3. Alignment: rkyv handles memory alignment for you, but it can result in slightly larger binary sizes due to padding bytes needed to align u64 or f64 fields.

Benchmark Strategy (Mental Model)
#

If we ran a criterion benchmark processing 10MB of data:

  • Serde JSON (Owned): ~150ms. Heavy allocator pressure.
  • Serde JSON (Cow): ~90ms. Faster, less GC pressure, but still parsing text.
  • Serde Bincode: ~20ms. Fast binary format, but usually requires deserialization step.
  • rkyv: ~0.01ms (Access time). The cost is essentially finding the pointer.

Part 5: Common Pitfalls and Best Practices
#

Implementing zero-copy techniques in production requires care. Here are the scars I’ve collected over the years so you don’t have to.

1. The Lifetime Hell
#

When using Serde with lifetimes:

struct User<'a> { name: &'a str }

This struct is now infectious. Any struct that contains User must also have a lifetime 'a. This bubbles up your entire architecture. Solution: Only use zero-copy DTOs (Data Transfer Objects) at the very edge of your application (network handler). Convert them to owned types if they need to persist long-term in memory, or process them immediately and discard.

2. Mutation
#

Archived types in rkyv are immutable by default. You cannot change archived.level because it is baked into the byte buffer. Solution: If you need to mutate data, you must “Deserialize” the rkyv object back into a standard Rust object (Deserialize trait), modify it, and re-serialize. rkyv is optimized for Write-Once-Read-Many (WORM) workloads.

3. Schema Evolution (Versioning)
#

With JSON, adding a field is easy. With raw memory dumps (rkyv), if the struct layout changes, the old bytes are garbage. Solution: rkyv has specific features for validation, but generally, it is best used for ephemeral data (network packets) or caches that can be invalidated. Do not use rkyv for long-term database storage unless you have a strict migration strategy.


Conclusion
#

Zero-copy deserialization is a potent tool in the Rust developer’s arsenal. It allows us to process data at the speed of memory bandwidth, removing the CPU overhead of parsing and the OS overhead of memory allocation.

Summary of Recommendations:

  1. Start with Standard Serde: For 95% of web applications, standard #[derive(Deserialize)] with serde_json is fine. Don’t optimize prematurely.
  2. Use Cow<'a, str>: If your profiler shows high allocation during JSON parsing, switch string fields to Cow.
  3. Adopt rkyv for Internal Systems: For communication between microservices, caching layers (Redis/Memcached), or save-files in games, rkyv offers unbeatable performance.

Rust gives you the choice: write high-level, readable code, or drop down and manage memory layouts manually. With libraries like Serde and rkyv, you can often get the best of both worlds.

Further Reading
#


Did you find this deep dive helpful? Share it with your team or subscribe to the Rust DevPro feed for more systems programming internals.