At high packet rates, parsing a binary protocol is not just about decoding fields. It is also about deciding who owns the payload—and whether decoding needs to copy it at all.

For a small packet, allocating a Vec<u8> and copying the payload may look harmless. At scale, those operations become part of the hot path. The question is whether we can parse the header, validate the packet, and hand off the payload without creating another allocation.
I built four implementations of the same binary protocol decoder to compare the trade-offs:
- A baseline parser that copies the payload into a
Vec<u8> - A
nomparser that borrows the payload - A manual parser using
Bytes - A hybrid parser using
nomfor the header andBytesfor payload ownership
The goal was not to find a universal winner. It was to understand what each approach buys us—and what it costs.
Benchmark results
I ran the implementations with Criterion on the same packet input. The measured average times were:
| Parser | Average |
|---|---|
Naive Vec<u8> | 43.17 ns |
Pure nom | 9.90 ns |
Manual Bytes | 23.94 ns |
nom + Bytes hybrid | 27.10 ns |
The pure nom parser was approximately 4.3× faster than the naive baseline in this benchmark.
That result is useful, but it needs context. These are microbenchmark numbers for a particular packet shape and machine. They are not a promise of the same speedup in a complete network service. Payload size, packet distribution, CPU architecture, buffer management, and the surrounding application all affect the result.
The key observation is that the baseline does more than parse. It also allocates a new vector and copies the payload into it. The other implementations change that ownership behavior.
1. The naive baseline: parse and copy
The protocol has an eight-byte header:
| Field | Size |
|---|---|
Magic bytes (RUST) | 4 bytes |
| Version | 1 byte |
| Message type | 1 byte |
| Payload length | 2 bytes, big-endian |
| Payload | Variable |
The baseline parser validates the header and copies the payload into a Vec<u8>.
pub struct Packet { pub version: u8, pub message_type: u8, pub payload_length: u16, pub payload: Vec<u8>, } #[derive(Debug)] pub enum ParseError { InvalidPacket, } pub fn parse_packet(input: &[u8]) -> Result<Packet, ParseError> { if input.len() < 8 || &input[..4] != b"RUST" { return Err(ParseError::InvalidPacket); } let version = input[4]; let message_type = input[5]; let payload_length = u16::from_be_bytes([input[6], input[7]]); if version != 1 { return Err(ParseError::InvalidPacket); } let expected_length = 8 + payload_length as usize; // This parser accepts exactly one packet. if input.len() != expected_length { return Err(ParseError::InvalidPacket); } let payload = input[8..].to_vec(); Ok(Packet { version, message_type, payload_length, payload, }) } The line that matters is:
let payload = input[8..].to_vec(); The input is borrowed, but the returned packet owns a new payload vector. That means the parser must allocate storage for the payload and copy the bytes into it.
This is straightforward and often perfectly reasonable. The returned packet is independent of the input buffer, which can be convenient when the packet needs to live beyond the buffer’s lifetime.
But it also means that parsing includes allocation and copying work.
2. Pure nom: borrow the payload
The next implementation uses nom to parse the protocol fields and returns a payload slice that borrows from the input.
use nom::{ bytes::complete::{tag, take}, combinator::all_consuming, number::complete::{be_u16, be_u8}, sequence::tuple, IResult, }; pub struct Packet<'a> { pub version: u8, pub message_type: u8, pub payload_length: u16, pub payload: &'a [u8], } pub fn parse_packet(input: &[u8]) -> Result<Packet<'_>, ParseError> { fn parse_fields(input: &[u8]) -> IResult<&[u8], Packet<'_>> { let (input, _) = tag(&b"RUST"[..])(input)?; let (input, (version, message_type, payload_length)) = tuple((be_u8, be_u8, be_u16))(input)?; let (input, payload) = take(payload_length)(input)?; Ok(( input, Packet { version, message_type, payload_length, payload, }, )) } let parsed = all_consuming(parse_fields)(input) .map_err(|_| ParseError::InvalidPacket)?; let (_, packet) = parsed; if packet.version != 1 { return Err(ParseError::InvalidPacket); } Ok(packet) } The important type is:
pub payload: &'a [u8] The payload is not copied. It is a slice into the original input buffer. The lifetime 'a expresses that relationship: the returned packet cannot outlive the bytes it references.
all_consuming enforces the parser’s strict policy: the input must contain exactly one complete packet. If bytes remain after the payload, parsing fails rather than silently accepting trailing data.
This implementation is fast because it avoids creating a new payload allocation. But the lifetime is not just a detail of the type signature—it affects how the packet can be used.
The lifetime friction in async code
Imagine a receiver that reads into a reusable buffer, parses a packet, and then wants to send the packet to a task:
let packet = parse_packet(&buffer)?; tokio::spawn(async move { process_packet(packet).await; }); This can run into a lifetime problem because packet borrows from buffer. The spawned task may outlive the function that owns that buffer.
The specific Rust bound to know here is 'static: tokio::spawn requires the spawned future to be Send + 'static. In this context, 'static means the future cannot contain a non-'static borrow that depends on a local buffer.
The issue is not that borrowed data can never be used in async code. It is that the spawned task needs ownership—or a borrow whose lifetime is valid for the task’s entire required lifetime. With a reusable receive buffer, that relationship is often inconvenient.
Pure nom is an excellent fit when the packet is processed synchronously or when the input buffer naturally stays alive for as long as the parsed packet is needed.
3. Bytes: shared ownership without a payload copy
The bytes crate offers another way to represent the payload. Instead of returning a borrowed slice or copying the payload into a Vec<u8>, the parser can return a Bytes view.
use bytes::Bytes; pub struct Packet { pub version: u8, pub message_type: u8, pub payload_length: u16, pub payload: Bytes, } pub fn parse_packet(input: &Bytes) -> Result<Packet, ParseError> { if input.len() < 8 || &input[..4] != b"RUST" { return Err(ParseError::InvalidPacket); } let version = input[4]; let message_type = input[5]; let payload_length = u16::from_be_bytes([input[6], input[7]]); if version != 1 { return Err(ParseError::InvalidPacket); } let expected_length = 8 + payload_length as usize; if input.len() != expected_length { return Err(ParseError::InvalidPacket); } let payload = input.slice(8..expected_length); Ok(Packet { version, message_type, payload_length, payload, }) } This line is the key:
let payload = input.slice(8..expected_length); Bytes::slice creates a view over a range of the existing byte storage. It does not copy the payload bytes into a new vector. The returned Bytes has shared ownership of the underlying storage, so it can outlive the original Bytes handle.
That changes the ownership story:
-
&[u8]borrows from the original input. -
Vec<u8>owns a copied payload. -
Bytesowns a handle to shared byte storage.
Bytes is designed for efficient byte-buffer sharing and is useful when payloads need to move between tasks or threads. Its exact internal representation is an implementation detail; the important API-level property is that a slice can share the backing storage rather than copying the selected bytes.
This makes Bytes a practical choice for asynchronous networking code. The packet can own its payload handle without requiring a borrow of the receiver’s reusable buffer.
4. The hybrid: nom for parsing, Bytes for ownership
The hybrid implementation combines the two approaches:
- Use
nomto parse and validate the header. - Use
Bytes::sliceto create the owned payload view.
use bytes::Bytes; use nom::{ bytes::complete::{tag, take}, combinator::all_consuming, number::complete::{be_u16, be_u8}, sequence::tuple, IResult, }; pub struct Packet { pub version: u8, pub message_type: u8, pub payload_length: u16, pub payload: Bytes, } pub fn parse_packet(input: &Bytes) -> Result<Packet, ParseError> { fn parse_header(input: &[u8]) -> IResult<&[u8], (u8, u8, u16)> { let (input, _) = tag(&b"RUST"[..])(input)?; let (input, (version, message_type, payload_length)) = tuple((be_u8, be_u8, be_u16))(input)?; Ok((input, (version, message_type, payload_length))) } let parsed = parse_header(input) .map_err(|_| ParseError::InvalidPacket)?; let (remaining, (version, message_type, payload_length)) = parsed; if version != 1 { return Err(ParseError::InvalidPacket); } let expected_length = 8 + payload_length as usize; if input.len() != expected_length || remaining.len() != payload_length as usize { return Err(ParseError::InvalidPacket); } // Validate that the declared payload is present. take::<usize, _, nom::error::Error<&[u8]>>(payload_length as usize)(remaining) .map_err(|_| ParseError::InvalidPacket)?; let payload = input.slice(8..expected_length); Ok(Packet { version, message_type, payload_length, payload, }) } The hybrid parser separates two concerns that often get mixed together:
Parsing: Are the bytes a valid packet, and what do the header fields mean?
Ownership: How can the payload remain available after the parser returns?
nom handles the structure of the input. Bytes handles the lifetime and ownership of the payload. The payload bytes are not copied into a new Vec<u8>.
This is a useful design when the codebase benefits from nom’s parser combinators but needs packets that can be owned and passed around independently of a receive buffer.
It is not automatically the fastest implementation. In this benchmark, the pure nom version was faster. The hybrid’s value is its ownership model and the way it fits into a larger async system—not a guarantee that combining libraries will improve throughput.
5. What the benchmark does—and does not—tell us
The benchmark measures the parsing implementations on the same input. It does not measure a complete network pipeline.
In a real service, the cost may include:
- Socket reads and buffer management
- Framing and incomplete-packet handling
- Payload processing
- Task scheduling and channel operations
- Backpressure and memory retention
The size and lifetime of the payload matter too. A tiny payload and a large payload can produce very different trade-offs. Shared ownership can avoid a copy, but it can also keep a larger backing allocation alive for longer than expected.
The benchmark also reports outliers. Those are worth noting, but they do not by themselves invalidate the results. Criterion’s reported average is a useful comparison point; it should be interpreted alongside the measurement interval and the environment in which the benchmark ran.
The takeaway is not “always use nom” or “always use Bytes.” It is to benchmark the work your application actually performs and choose an ownership model that fits the data’s lifetime.
Conclusion
The naive parser is simple and gives the returned packet independent storage, but it allocates and copies the payload.
Pure nom avoids that copy and performed best in this microbenchmark. Its borrowed payload is especially convenient when parsing and processing happen within the lifetime of the input buffer.
Bytes offers a different trade-off: shared ownership of byte storage. It makes it easier to pass payloads across task boundaries without tying the packet to a local receive buffer.
The hybrid approach combines nom’s parsing style with Bytes’ ownership model. It is a practical option when both are useful, though its performance should be measured rather than assumed.
Zero-copy parsing is not just a trick for shaving nanoseconds off a function. It is a design choice about who owns the bytes, how long they live, and where copying belongs in the system.
Questions and discussion
I wrote this because parsing a packet is only part of the problem—the ownership model can shape the rest of the networking code.
I’d be interested to hear how others approach this:
Do you prefer borrowed slices, Bytes, or another buffer type for packet payloads?
Have you run into lifetime friction when passing parsed data into tokio::spawn?
What would you benchmark beyond parser latency when evaluating a real network service?
If you’ve used a different approach, leave a comment. I’d love to compare notes.
Author’s note: I built and benchmarked the parser implementations as part of my Rust learning process. I used AI assistance to help organize and edit this article; the project and benchmark results are my own.



