The buffer bottleneck
You are building a network service. Packets arrive in chunks, you parse headers, and you hand payloads to worker tasks. You reach for Vec<u8> because it feels familiar. You clone the buffer for every handler. Memory usage doubles. You switch to &[u8] to avoid copying. The borrow checker immediately rejects you because the original buffer lives longer than the slice, or you cannot store the slice in a struct that outlives the function. You are stuck between copying too much and fighting lifetimes.
The bytes crate breaks this deadlock. It provides two types: Bytes for immutable, zero-copy sharing, and BytesMut for efficient mutation. These types are the standard for high-performance networking in Rust. Tokio, Hyper, and most binary protocol parsers depend on them. They let you slice, share, and mutate buffers without the allocation tax or lifetime traps of Vec<u8> and &[u8].
How zero-copy actually works
Bytes solves the sharing problem with reference counting and offset slicing. When you create a Bytes, the data lives on the heap. When you clone a Bytes, you do not copy the data. You bump a reference count and hand back a new handle pointing to the same allocation. When you slice a Bytes, you do not copy the data either. You create a new handle that points to the same allocation but with a different offset and length.
Think of Bytes like a shared hard drive partition. Multiple users can mount the same partition. No one copies the files. Everyone reads the same content. If you want to share just a single folder, you do not copy the folder. You give them a path pointing to that folder inside the same partition. When the last user unmounts the partition, the storage is reclaimed.
BytesMut is the mutable counterpart. It manages its own capacity and avoids reallocation when you split or shrink it. When you are done writing, you can lock it and turn it into a Bytes to share read-only. The underlying memory layout keeps a pointer, length, capacity, and reference count. Slicing only changes the length and offset. The reference count ensures the buffer stays alive as long as any handle points to it.
Minimal example
Here is the core API in action. You can create Bytes from static data, clone it cheaply, slice it without copying, and mutate with BytesMut before freezing it.
use bytes::{Bytes, BytesMut};
/// Demonstrate zero-copy operations with Bytes.
fn main() {
// Static data lives in the binary. No heap allocation occurs.
let b = Bytes::from_static(b"Hello, world!");
// Clone increments the internal reference count. Zero bytes copied.
let c = b.clone();
// Slice adjusts the offset and length. The underlying buffer is shared.
let sub = b.slice(..5);
// BytesMut owns a growable buffer. It tracks capacity separately.
let mut buf = BytesMut::from(&b"Hello"[..]);
buf.extend_from_slice(b" world");
// Freeze consumes the mutable buffer and returns an immutable Bytes.
let immutable = buf.freeze();
}
Zero-copy is not magic. It is careful pointer arithmetic and reference counting. Trust the counter.
What happens under the hood
When you call Bytes::from_static, the crate stores a pointer to the read-only segment of your binary, a length of 13, and a special reference count value that signals "static data, never free." Cloning this handle simply increments that count. Slicing it creates a new handle with an offset of 0 and a length of 5. Both handles point to the exact same memory region.
When you use heap-allocated data, the reference count starts at one. Each clone adds one. Each drop subtracts one. When the count reaches zero, the allocator frees the memory. Slicing never changes the reference count. It only changes the view. The original Bytes and the slice can live completely independently. Dropping the original does not invalidate the slice. Dropping the slice does not invalidate the original.
BytesMut adds a capacity field. It grows geometrically when you push data, just like Vec<u8>. When you call split_to, it carves off a prefix and returns it as a new BytesMut. The remaining buffer keeps the original capacity. This avoids reallocation when you continue reading from the network. The tradeoff is that you hold onto memory longer than strictly necessary. Call shrink_to_fit() if you need to release it.
Convention aside: the community prefers Bytes::from_static for compile-time constants and Bytes::from for owned vectors. Both work, but the explicit name signals intent to readers.
Parsing a real protocol
Real code uses BytesMut to assemble fragmented data and split_to to extract complete messages. Here is a realistic example of parsing a length-prefixed protocol. The buffer arrives in chunks. You accumulate data until you have a full message, then split it off.
use bytes::{Bytes, BytesMut};
/// Parse a length-prefixed message from a buffer.
/// Returns the message payload and advances the buffer.
fn parse_message(buf: &mut BytesMut) -> Option<Bytes> {
// Need at least 4 bytes for the length header.
if buf.len() < 4 {
return None;
}
// Read the length from the first 4 bytes.
// This does not copy data; it just reads the slice.
let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
// Check if the full message is present.
if buf.len() < 4 + len {
return None;
}
// Advance past the header. The buffer shrinks logically.
buf.advance(4);
// Split off the message payload. Returns a new BytesMut.
let payload = buf.split_to(len);
// Freeze the payload to turn it into a shareable Bytes.
Some(payload.freeze())
}
/// Demonstrate parsing multiple messages.
fn main() {
// Simulate a buffer with two messages.
// Message 1: length 5, payload "Hello".
// Message 2: length 5, payload "World".
let mut buf = BytesMut::from(
&[0, 0, 0, 5, b'H', b'e', b'l', b'l', b'o', 0, 0, 0, 5, b'W', b'o', b'r', b'l', b'd'][..]
);
// Parse first message.
let msg1 = parse_message(&mut buf).expect("First message");
assert_eq!(&msg1[..], b"Hello");
// Parse second message.
let msg2 = parse_message(&mut buf).expect("Second message");
assert_eq!(&msg2[..], b"World");
// Buffer is now empty.
assert_eq!(buf.len(), 0);
}
Freeze early. Once data is immutable, turn it into Bytes and share it across tasks.
Pitfalls and compiler traps
Bytes behaves differently from Vec<u8> and &[u8]. Watch for these traps.
Mismatched types. Bytes is not &[u8]. If a function expects &[u8], you cannot pass Bytes directly. You will get E0308 (mismatched types). Bytes implements Deref<Target=[u8]>, so you can dereference it. Pass &buf[..] to get a slice. Bytes also implements AsRef<[u8]>, so functions accepting AsRef<[u8]> work seamlessly. Convention aside: &buf[..] is the idiomatic way to borrow a Bytes as a slice. It reads clearly and avoids accidental ownership transfers.
Freeze consumes. BytesMut::freeze() takes ownership of the BytesMut. You cannot call it on a reference. If you try buf.freeze() where buf is &mut BytesMut, the compiler rejects it. You must move the BytesMut into freeze. This enforces the rule that once data is frozen, no one can mutate it.
Capacity surprises. BytesMut maintains capacity. When you call split_to, the remaining buffer might keep excess capacity. This is usually good. It avoids reallocation when you append more data. But if you have many small splits, you might hold onto memory longer than expected. Call shrink_to_fit() if you need to release capacity.
Bytes versus Arc<[u8]>. Arc<[u8]> requires allocating the array upfront. Slicing an Arc<[u8]> is difficult. You cannot easily create a slice that shares the allocation without copying or using unsafe code. Bytes handles slicing safely and efficiently. Bytes is the right tool for binary buffers.
Do not fight the compiler here. Reach for Bytes when you need zero-copy sharing.
Choosing the right buffer
Pick the type that matches your usage pattern. Each type has a specific role.
Use Bytes when you need to share immutable binary data across threads or tasks without copying. Use Bytes when you are building a network stack and want zero-copy slicing of incoming packets. Use Bytes when you have parsed data that needs to be stored in a struct and shared with other parts of the system.
Use BytesMut when you are assembling a message to send, or buffering incoming data that needs to grow. Use BytesMut when you need to modify binary data in place, such as updating headers or appending payloads. Use BytesMut when you want to avoid reallocation during incremental parsing.
Use Vec<u8> when you need a simple, owned buffer and do not care about zero-copy sharing or slicing. Use Vec<u8> for small, short-lived buffers where the overhead of Bytes is not worth it. Use Vec<u8> when you need to push and pop elements frequently and do not need to share the buffer.
Use &[u8] when you just need to read a slice of data and lifetimes are simple. Use &[u8] for function arguments where you accept any byte source. Use &[u8] when the data lives in a single scope and does not need to be stored or shared.
Use Arc<[u8]> when you need thread-safe sharing of a fixed array and do not need slicing. Use Arc<[u8]> when you are interoperating with APIs that require Arc. Avoid Arc<[u8]> for binary protocols where slicing is common.
Counter-intuitive but true: the more you use Bytes, the less you allocate. The buffer lives longer, but the cost per operation drops.