Why Rust is the Future of Systems Programming
I spent the first two hours of a 2019 flight fighting error[E0505]: cannot move out of 's' because it is borrowed. By the time we landed, I had fixed exactly one function and learned to read the compiler's error messages like they were a map of my own poor decisions. It was humiliating. It was also the most honest a compiler has ever been with me.
Since then I've written a few thousand lines of Rust across a handful of real projects: a terminal task tracker, a static file server, a Redis-protocol toy, and a packet sniffer built on libpcap. They all live in the rust-projects repository. None of them are production-grade infrastructure, but all of them taught me something about where Rust shines and where it still cuts.
This post is the summary of those lessons. Not a tutorial, not a cheerleading post — a report from someone who has fought the borrow checker, measured the performance claims, and decided that the tradeoffs are worth it. The short version: Rust is the future of systems programming not because it is pleasant, but because it makes the compiler enforce the things we always knew we should do and never did.
The Ownership Revolution
The core idea: every value has exactly one owner. When the owner goes out of scope, the value is dropped. No garbage collector, no manual free, no reference counting by default. The consequence most people miss is that memory safety becomes a property of the type system, not a runtime feature. The compiler proves that your code cannot use-after-free, cannot double-free, cannot read uninitialized memory — at compile time, for zero runtime cost.
Here is a small example from my log-processing tool — a LogLine parser that owns a String and borrows pieces of it:
#[derive(Debug, PartialEq)]
enum LogLevel {
Info,
Warn,
Error,
}
struct LogLine {
raw: String,
level: LogLevel,
}
impl LogLine {
// `parse` takes ownership of the raw string.
// After this call, the caller can't touch `raw` anymore.
fn parse(raw: String) -> Self {
let level = if raw.contains("ERROR") {
LogLevel::Error
} else if raw.contains("WARN") {
LogLevel::Warn
} else {
LogLevel::Info
};
LogLine { raw, level }
}
// `message` borrows `self` immutably and returns a slice
// that borrows `self.raw`. The lifetime is elided but real.
fn message(&self) -> &str {
self.raw.split("] ").nth(1).unwrap_or("")
}
}
fn main() {
let raw = "[ERROR] disk full".to_string();
let line = LogLine::parse(raw);
// println!("{raw}"); // would not compile: `raw` was moved
println!("{:?}: {}", line.level, line.message());
}The thing to notice: parse consumes the String. If the caller tries to use it afterward, the compiler refuses. In C, that is a use-after-free waiting to happen. In Rust, it is a compile error with a suggested fix. The message method returns a &str that borrows from self, and the compiler checks — at every call site — that the borrow does not outlive the LogLine. That single check removes an entire class of bugs that cost the C and C++ worlds billions of dollars.
Fearless Concurrency Is Real, With Caveats
Rust's concurrency story is built on two marker traits: Send (a type can be moved to another thread) and Sync (a type can be shared between threads). The compiler enforces them at compile time. This means a whole category of data races — two threads mutating the same memory without synchronization — become compile errors instead of production incidents.
In my HTTP static file server, I used a thread pool with an mpsc channel to distribute connections. The earliest version used Arc<Mutex<Vec<Connection>>> as shared state. It compiled, it worked, and it was boring. That is the point: Rust makes the safe version the default, and the unsafe version requires an explicit unsafe block with a comment explaining why it is sound.
Here is a stripped-down worker pool from that project:
use std::sync::mpsc;
use std::thread;
struct Worker {
id: usize,
handle: thread::JoinHandle<()>,
}
fn spawn_pool(size: usize, rx: mpsc::Receiver<String>) -> Vec<Worker> {
(0..size)
.map(|id| {
let rx = rx.clone(); // each worker gets its own receiver clone
let handle = thread::spawn(move || {
for job in rx {
println!("worker {id} processing: {job}");
}
});
Worker { id, handle }
})
.collect()
}The move closure forces the thread to take ownership of its receiver. The compiler checks that mpsc::Receiver<String> is Send — it is — and that String is Send — it is. If I tried to send a raw pointer or an Rc<String> through the same channel, the compiler would reject the program. That is what fearless concurrency means: not that concurrency is easy, but that the dangerous class of bugs is caught before the code runs.
The caveat: the guarantee only covers data races between threads in safe Rust. Deadlocks are still possible. Starvation is still possible. Lock contention is still a performance problem. And the moment you write unsafe, you are on your own.
The Trait System: Contracts Without Inheritance
Traits are Rust's version of interfaces, but they are more flexible than Java or C# interfaces because they can be implemented for any type, including types you do not own. You can define behavior for external types without wrapping them in classes.
When I wrote a small file syncer, I needed two checksum algorithms — a quick xor-based one for local deduplication and a stronger additive one for remote verification. Instead of a class hierarchy, I defined a Checksum trait and implemented it for both:
trait Checksum {
fn update(&mut self, data: &[u8]);
fn hex(&self) -> String;
}
struct Xor8 {
acc: u8,
}
impl Checksum for Xor8 {
fn update(&mut self, data: &[u8]) {
self.acc ^= data.iter().fold(0u8, |a, b| a ^ b);
}
fn hex(&self) -> String {
format!("{:02x}", self.acc)
}
}
struct Add32 {
acc: u32,
}
impl Checksum for Add32 {
fn update(&mut self, data: &[u8]) {
self.acc = self
.acc
.wrapping_add(data.iter().fold(0u32, |a, &b| a + b as u32));
}
fn hex(&self) -> String {
format!("{:08x}", self.acc)
}
}
fn checksum<C: Checksum>(mut c: C, data: &[u8]) -> String {
c.update(data);
c.hex()
}
fn main() {
let data = b"hello rust";
println!("xor8: {}", checksum(Xor8 { acc: 0 }, data));
println!("add32: {}", checksum(Add32 { acc: 0 }, data));
}The generic function checksum works with any type implementing Checksum. No virtual dispatch, no vtable lookup — the compiler monomorphizes the function for each concrete type, so the performance is identical to writing the function twice by hand. This is the zero-cost abstraction story in practice: the trait gives you polymorphism, and the compiler removes the indirection.
The tradeoff: generic functions with many trait bounds can blow up compile times and produce error messages that look like a small novel. We will get to that.
Error Handling That Survives Refactors
Rust's Result<T, E> type forces you to handle errors at the call site. There is no exception that can fly past three layers of code and get caught somewhere unrelated. This has a surprising benefit: when a function changes its error type, the compiler finds every call site that needs updating. In C, updating a function's error contract is a manual archaeology project. In Rust, it is a compile cycle.
My packet sniffer used a custom error enum for its different failure modes:
use std::fmt;
#[derive(Debug)]
enum SnifferError {
PermissionDenied(String),
InterfaceNotFound(String),
MalformedPacket(String),
Io(std::io::Error),
}
impl fmt::Display for SnifferError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SnifferError::PermissionDenied(iface) => {
write!(f, "no permission to capture on interface {iface}")
}
SnifferError::InterfaceNotFound(iface) => {
write!(f, "interface {iface} not found")
}
SnifferError::MalformedPacket(desc) => write!(f, "malformed packet: {desc}"),
SnifferError::Io(err) => write!(f, "I/O error: {err}"),
}
}
}
impl std::error::Error for SnifferError {}
impl From<std::io::Error> for SnifferError {
fn from(err: std::io::Error) -> Self {
SnifferError::Io(err)
}
}
fn read_pcap_file(path: &str) -> Result<Vec<u8>, SnifferError> {
let bytes = std::fs::read(path)?; // `?` uses the From impl above
if bytes.is_empty() {
return Err(SnifferError::MalformedPacket("empty file".into()));
}
Ok(bytes)
}The ? operator is the key ergonomic win. It unwraps the successful value or returns the error from the current function — but only if the error can be converted into the function's declared error type. The From impl makes that conversion explicit and compiler-checked. You cannot accidentally drop an error's context.
The cost: many small error types and a pile of From impls that feel like boilerplate — until they save your ass in a refactor. Crates like thiserror and anyhow reduce the boilerplate, but the underlying design, explicit errors with explicit propagation, is the right one.
Zero-Cost Abstractions, Measured
The phrase "zero-cost abstraction" gets thrown around a lot. What it means in Rust: an abstraction that is semantically equivalent to hand-written lower-level code should compile to the same machine code. The iterator chain items.iter().filter(...).map(...).collect() should be as fast as a handwritten for loop. Usually it is, because both compile down to the same loop.
I measured this once. A log-processing tool I wrote in Python processed roughly 8,000 lines per second. The same logic in Rust — iterators, no manual optimization, no unsafe — processed about 1.2 million lines per second. That is a 150x difference. It is not Rust being magical; it is a real compiler, no interpreter, and abstractions that do not add runtime cost. The same tool consumed about a tenth of the memory, because I could store log lines as borrowed &str slices instead of copying every field into Python objects.
Here is the table I use when someone asks whether they should rewrite X in Rust:
| Scenario | Rust | C/C++ | Go | Python | |---|---|---|---|---| | Latency-sensitive service | Strong | Strong | Good | Weak | | Memory safety by default | Yes | No | Yes | Yes | | Predictable memory usage | Yes | Yes | GC pauses possible | No | | Team ramp-up time | 1-2 months | 1 month | 1-2 weeks | 1 week | | Compile times | seconds-minutes | seconds | seconds | n/a | | FFI to C | Painless | Native | CGo pain | ctypes pain |
The honest reading: Rust wins when you need performance and safety at the same time, and you pay for it in compile times and learning curve. For a CRUD API with a 50ms latency budget, Go is the sane choice. For a video codec, a database engine, or a packet sniffer, Rust is the strongest option on the table.
The Toolchain That Feels Like a Copilot
Cargo is the best build tool I have used, and it is not close. cargo build, cargo test, cargo bench, cargo clippy, cargo fmt — one tool, one command surface, consistent across every Rust project. The Cargo.toml manifest is declarative, and cargo add updates it without me having to read the format again. Dependency management, versioning, feature flags, workspace layout — all of it is solved, and solved well.
Clippy deserves special mention. It is a linter with hundreds of rules, and it catches real bugs, not style nits. The most valuable rule I rely on: clippy::pedantic flags unnecessary clone() calls, unwrap() usage, and a dozen other footguns. Running it in CI with -D warnings turns the linter into part of the compiler:
$ cargo clippy -- -D warnings
error: using `clone` on a double reference
--> src/config.rs:37:21
|
37 | let name = config.name.clone();
| ^^^^^^^^^^^^
|
help: try dereferencing the `&&str`
|
37 | let name = config.name;That is a real error I hit: I had a &&str and called .clone(), which cloned the reference, not the string. Clippy caught it before I shipped it. The compiler itself caught dozens more errors that would have been segfaults in C. When people ask why Rust projects have fewer bugs, part of the answer is literally staring at them in the error messages.
The Borrow Checker Fights Back
Let me be honest about the pain. The borrow checker rejected my code hundreds of times. The worst cases:
Lifetime annotations in structs
"Missing lifetime specifier" is a rite of passage. The fix is to decide whether the struct owns its data or borrows it — and owning it (String instead of &str) is usually the right call for application code. Every time I tried to optimize early by storing a reference, I ended up rewriting the struct to own the data anyway.
Self-referential structs
You cannot easily have a struct that holds a reference to its own field. This is a known limitation, and the standard workaround is to store an index into a Vec rather than a pointer. The compiler is telling you something true: self-referential data is brittle, and the index approach is safer anyway.
Closures that capture
A closure that captures a reference, then gets passed to another thread, will fight you until you move the owned data into the closure. This took me a full afternoon to understand — the error messages kept pointing at the closure, but the real problem was my mental model of what the closure captured.
My advice after three projects: if you fight the borrow checker for more than 20 minutes, stop and redesign. The refusal is usually pointing at a real design problem. Reorganize the data so that ownership is clear — one owner, explicit borrows — and the compiler stops complaining. It is humbling, but it produces better code. Expect two to four weeks of frustration before you are productive. After that, the model becomes intuitive, and you start writing C with a vague sense of dread, because you know the bugs you are not catching.
Ecosystem Gaps We Hit
Rust's ecosystem is maturing quickly, but it is not complete. Here are the gaps I actually hit:
GUI frameworks
egui, iced, and slint are improving fast, but none is at the level of Qt or Electron. If your product is a desktop GUI, Rust is a risky bet for the UI layer. Use it for the core logic and wrap the UI in something else.
Machine learning
candle and burn exist, but the heavyweight ecosystem (PyTorch, TensorFlow) is Python-first. Rust can call into those libraries via FFI, but you are writing glue code, not using a first-class Rust stack.
Web frameworks
axum and actix-web are excellent and production-ready. This is not a gap anymore. If you are building HTTP services, Rust is a first-class citizen.
Async runtime fragmentation
tokio, async-std, and smol competed for mindshare, and async traits only recently stabilized. The ecosystem has mostly settled on tokio, but the fragmentation cost real time in the early days.
None of these are blockers for systems programming, which remains Rust's core strength. They matter if you are evaluating Rust for a different domain.
Interop: Rust and C Sitting in a Tree
The most common migration path is not "rewrite everything in Rust." It is "keep the C library, write the new logic in Rust, and call it from the existing system." Rust FFI to C is straightforward, and bindgen generates Rust bindings from C headers automatically.
I did this with the packet sniffer: the capture layer was libpcap, a C library, and all the parsing and analysis was Rust. The binding step was one command:
$ bindgen /usr/include/pcap/pcap.h -o src/pcap_bindings.rsThe generated bindings were ugly but correct. The unsafe calls into libpcap were isolated in a single module, and the rest of the codebase was safe Rust. The key lesson: unsafe is a containment tool, not a permission slip. Keep unsafe blocks small, document the invariants they rely on, and audit them carefully. The compiler will not catch mistakes inside unsafe — that is the price of the escape hatch. But a small, audited unsafe surface is far better than a codebase with no safety guarantees at all.
- Ownership gives you memory safety without a garbage collector: the compiler proves it at compile time, so the runtime cost is zero.
- `Send` and `Sync` catch data races before the code runs, but they do not prevent deadlocks or lock contention.
- Traits are zero-cost interfaces; generic functions monomorphize, so there is no vtable overhead.
- Explicit `Result` types make refactors safer because the compiler points you at every call site that needs to change.
- Clippy with `-D warnings` in CI is a cheap way to enforce good practices on every commit.
- Encapsulate `unsafe` in small, documented modules; audit it like your career depends on it.
01How long does it take to learn Rust?
02Is Rust good for web development?
03Do I have to write unsafe in normal Rust code?
04What is the biggest downside of Rust?
Conclusion
Rust is not the future of systems programming because it is pleasant to write — it is not, not all the time. It is the future because it makes the things we already know how to do — manage memory, use threads, handle errors — into things the compiler verifies for us. The borrow checker took me weeks to tolerate and months to appreciate, but I no longer want to write systems code the other way. The freedom of printing to stdout without wondering whether the buffer is still alive is hard to give up.
That said, the honest position is that Rust is not for everyone or everything. If your project is a web service with no performance constraint, Go is the right tool. If you are maintaining a legacy C codebase with no appetite for a rewrite, keep using C. But for new projects that need performance and safety in the same place, I cannot name a stronger default.
The other thing I tell people: the only way to learn Rust is to build something real. Reading the book is necessary, but the lessons stick when they come from your own compile errors. My own attempts are scattered across the rust-projects repository — a task tracker, a packet sniffer, a static file server, and more. They are not elegant, but they are compiled, tested, and I learned something from each one.
If you are on the fence, start small: write a CLI tool that processes text files, add threading, then add a network component. The compiler will be your toughest reviewer and your best teacher. View the project on GitHub.