Zero-copy Twitch IRC parsing in Rust with Bytes + nom at 5k/s
Goal and constraints
- Target at least 5,000 PRIVMSG per second from Twitch IRC.
- Zero-copy from socket to parsed view.
- No per-message heap churn: no String/Vec growth, no transient hash maps.
- Backpressure-aware: avoid spawning per line; use bounded channels.
The approach is straightforward: frame with BytesMut, split_to on CRLF, freeze into Bytes, then parse with nom over borrowed &str slices. The parsed view holds references into the immutable Bytes, so nothing is copied. For small dynamic lists like tags and params, SmallVec keeps the fast path on the stack.
Quick refresher: Twitch IRC line shape
Example (CRLF-terminated):
@badge-info=;badges=broadcaster/1;color=#1E90FF;display-name=Foo;id=abc-123;mod=0 :foo!foo@foo.tmi.twitch.tv PRIVMSG #mychan :Hello, world!
Grammar sketch (simplified):
- Optional tags:
@key=val;key=val ...<space> - Optional prefix:
:<prefix><space> - Command:
PRIVMSG|PING|... - Params: space-separated; the last one may start with
:to allow spaces - Terminator:
\r\n
Borrowed data model (no allocations)
We expose a view that borrows slices from the inbound frame. The frame is a Bytes, cheap to clone and refcounted. The parsed struct borrows &'a str slices out of that Bytes.
use smallvec::SmallVec;
#[derive(Debug)]
pub struct IrcMsg<'a> {
pub tags: SmallVec<[(&'a str, &'a str); 16]>,
pub prefix: Option<&'a str>,
pub command: &'a str,
pub params: SmallVec<[&'a str; 4]>,
pub trailing: Option<&'a str>, // the last param starting with ':'
}
A stack capacity of 16 tags matches typical Twitch metadata; 4 params is enough for PRIVMSG and the usual commands. Tune if your traffic demands it.
Zero-copy nom parser
Nom fits borrowed inputs well; feed it a &str that came from Bytes. No cloning, no intermediate String.
use nom::{
IResult,
branch::alt,
bytes::complete::{is_not, tag, take_till1, take_while1},
character::complete::space1,
combinator::{map, opt},
multi::separated_list0,
sequence::{preceded, separated_pair, tuple},
};
fn is_tag_key_char(c: char) -> bool {
// RFC1459-ish token; conservative for Twitch tags
match c {
'=' | ';' | ' ' | '\r' | '\n' => false,
_ => true,
}
}
fn is_tag_val_char(c: char) -> bool {
match c {
';' | ' ' | '\r' | '\n' => false,
_ => true,
}
}
fn parse_tags(input: &str) -> IResult<&str, SmallVec<[(&str, &str); 16]>> {
let kv = separated_pair(
take_while1(is_tag_key_char),
tag("="),
take_while1(is_tag_val_char),
);
let (i, pairs) = preceded(tag("@"), separated_list0(tag(";"), kv))(input)?;
let (i, _) = tag(" ")(i)?; // space after tags
let mut out: SmallVec<[(&str, &str); 16]> = SmallVec::new();
out.extend(pairs);
Ok((i, out))
}
fn parse_prefix(input: &str) -> IResult<&str, &str> {
preceded(tag(":"), take_till1(|c| c == ' '))(input)
}
fn parse_command(input: &str) -> IResult<&str, &str> {
// e.g. PRIVMSG, PING, NOTICE
take_while1(|c: char| c != ' ' && c != '\r' && c != '\n')(input)
}
fn parse_params(input: &str) -> IResult<&str, (SmallVec<[&str; 4]>, Option<&str>)> {
// params until optional trailing that starts with ':'
let mut i = input;
let mut params: SmallVec<[&str; 4]> = SmallVec::new();
let mut trailing: Option<&str> = None;
loop {
if let Ok((i2, _)) = tag::<_, _, nom::error::Error<_>>(" ")(i) { i = i2; } else { break; }
if let Ok((i2, t)) = preceded(tag(":"), map(is_not("\r\n"), |s: &str| s))(i) {
trailing = Some(t);
i = i2;
break;
}
let (i2, p) = take_while1(|c: char| c != ' ' && c != '\r' && c != '\n')(i)?;
params.push(p);
i = i2;
}
Ok((i, (params, trailing)))
}
pub fn parse_irc_line<'a>(line: &'a str) -> IResult<&'a str, IrcMsg<'a>> {
let (i, tags) = opt(parse_tags)(line)?;
let (i, prefix) = opt(tuple((parse_prefix, space1)))(i)?;
let (i, cmd) = parse_command(i)?;
let (i, (params, trailing)) = parse_params(i)?;
Ok((i, IrcMsg {
tags: tags.unwrap_or_else(SmallVec::new),
prefix: prefix.map(|(p, _)| p),
command: cmd,
params,
trailing,
}))
}
On the happy path it doesn’t allocate; it slices right into the &str sourced from Bytes. No per-field copies either. If you need tag escape decoding or normalization, do it lazily for the fields that matter.
Framing with BytesMut and zero-copy splits
Tokio’s AsyncReadExt::read_buf fills a BytesMut. Scan for CRLF, split_to the frame, then freeze to Bytes. Freezing avoids a copy and yields an immutable shared view.
use tokio::{io::AsyncReadExt, net::TcpStream};
use bytes::{Bytes, BytesMut, Buf};
use memchr::memmem::Finder;
pub async fn read_frames(mut stream: TcpStream, tx: tokio::sync::mpsc::Sender<Bytes>) -> anyhow::Result<()> {
let mut buf = BytesMut::with_capacity(64 * 1024);
let finder = Finder::new(b"\r\n");
loop {
// READ: grow if needed; read_buf avoids realloc when capacity exists
let n = stream.read_buf(&mut buf).await?;
if n == 0 { break; }
// SCAN: find CRLF boundaries and emit frames
while let Some(pos) = finder.find(&buf) {
// Split the line (without CRLF)
let mut line = buf.split_to(pos);
// Drop CRLF from the original buffer
buf.advance(2);
// Freeze to an immutable Bytes without copying
let bytes = line.freeze();
if tx.try_send(bytes).is_err() {
// Apply backpressure: if full, await once then retry
tx.send(Bytes::new()).await.ok(); // or handle properly
}
}
// Optional: compact when buffer is mostly unused
if buf.capacity() > 256 * 1024 && buf.len() < 8 * 1024 { buf.reserve(0); }
}
Ok(())
}
Why not use tokio-util’s LinesCodec? Because it allocates Strings and does UTF-8 checks per chunk. We keep the data as Bytes and validate UTF-8 only when parsing.
Parse where the data lands (ownership boundaries)
Bytes is refcounted. Pass Bytes across tasks and parse on the consumer side to avoid self-referential types. The IrcMsg<'a> borrows from a Bytes that’s alive in the same frame.
use bytes::Bytes;
use tokio::sync::mpsc;
pub async fn run(mut rx: mpsc::Receiver<Bytes>) {
let mut count = 0u64;
let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
tokio::select! {
Some(frame) = rx.recv() => {
// Borrow a &str without copying
let s = match std::str::from_utf8(&frame) {
Ok(s) => s,
Err(_) => continue, // or lossily map; Twitch is UTF-8
};
match parse_irc_line(s) {
Ok((_rest, msg)) => {
// msg borrows from s, which borrows from frame
if msg.command == "PRIVMSG" {
// Hot path: use borrowed slices
let _chan = msg.params.get(0).copied();
let _text = msg.trailing;
}
count += 1;
}
Err(_e) => { /* ignore malformed */ }
}
// msg drops here before next iteration
}
_ = tick.tick() => {
eprintln!("throughput: {} msgs/s", count);
count = 0;
}
}
}
}
Keep lifetimes local: don’t store IrcMsg beyond the loop. If you must persist, keep the Bytes and re-parse on access, or copy only the fields you truly need.
Hitting 5,000+/s: practical notes
- Kill per-line allocations:
- BytesMut + split_to + freeze; never String::from_utf8.
- SmallVec keeps tags/params on the stack in the common case.
- Fewer syscalls: read 64–128 KiB chunks and frame in memory.
- Use memchr/memmem to find CRLF; it’s vectorized and fast.
- Keep parsing single-threaded unless you see CPU saturation; the socket is usually first to bottleneck.
- If you fan out, send Bytes and parse at the edge.
- Use a bounded mpsc and handle backpressure intentionally.
Benchmark sketch with Criterion
A microbench isolates parser cost from I/O. It feeds realistic lines and checks that the steady state avoids allocations (confirm with your heap profiler).
use criterion::{criterion_group, criterion_main, Criterion, black_box};
fn gen_line() -> String {
format!(
"@badge-info=;badges=subscriber/1;color=#1E90FF;display-name=Foo;id={};mod=0 :foo!foo@foo.tmi.twitch.tv PRIVMSG #chan :Hello world!\r\n",
uuid::Uuid::new_v4()
)
}
fn bench_parse(c: &mut Criterion) {
let lines: Vec<_> = (0..10_000).map(|_| gen_line()).collect();
c.bench_function("parse_irc_line", |b| {
b.iter(|| {
for l in &lines {
let s = &l[..l.len()-2]; // trim CRLF; framing does this
let (_rest, msg) = parse_irc_line(black_box(s)).unwrap();
black_box(msg);
}
})
});
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);
On a modern laptop, the parser alone clears >1e6 lines/s. The 5k/s end-to-end target is set by the network and framing loop. Build with --release, lto = true, and codegen-units = 1.
Common pitfalls
- tokio::io::BufReader::lines() allocates a String per line and only trims
\n; you need CRLF and zero-copy. - Building a HashMap of tags per message hammers the allocator; prefer a SmallVec of pairs or a tiny arena only if you truly need lookup.
- Splitting on
\nalone leaves a stray\rbecause Twitch uses\r\n. - Holding parsed views past the frame lifetime doesn’t work; re-parse or copy the minimal data you need.
Extensions
- Convert common tags to typed fields on first access; look up the pair and parse then.
- Support PING/NOTICE/USERSTATE with the same combinators.
- If partial Unicode normalization is required, apply it on-demand to
trailingonly. - Pre-size the read buffer for expected peak line density to limit reallocations.
TL;DR architecture
- Socket -> BytesMut (read_buf)
- Frame by scanning for CRLF -> split_to -> freeze Bytes
- Consumer receives Bytes, converts to &str, parses with nom into borrowed slices
- Work with borrowed fields; copy minimally if something must outlive the frame
This keeps the hot path off the heap, parsing stays branch-light and cache-friendly, and sustaining 5,000+ Twitch chat messages per second on commodity hardware is routine.
Need an engineer who can build this?
I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.