Memory Safety Without a Garbage Collector, What Rust Actually Guarantees
TL;DR — Rust eliminates use-after-free, double-free, data races, and most buffer overflows at compile time, without a garbage collector / What it does NOT eliminate: logic bugs, integer overflow in release mode (by default), panics, deadlocks, memory leaks / The trade is a steeper learning curve for a sharper boundary on what can go wrong at runtime
When I tell engineers from GC’d backgrounds that Rust gives you memory safety without a garbage collector, the most common response is “that’s not possible.” It is possible, but the trick is in how the word “safety” is defined. Rust’s guarantees are specific and bounded, and understanding the boundary is the difference between using the language well and being surprised by it.
This post walks through what’s actually guaranteed, what isn’t, and why this matters for backend services where security and reliability are first-class concerns. If you’ve read my previous post on ownership and borrowing, this is the natural follow-up — those rules are the mechanism, this is the guarantee they buy you.
The CWE Top 25 framing
Google, Microsoft, and the Chromium project have all published numbers in the same ballpark: ~70% of severe security bugs in C/C++ codebases are memory safety bugs. Specifically: use-after-free, buffer overflows, double-free, type confusion, race conditions on shared state. These are the bugs that get CVE numbers and emergency patches.
Rust’s value proposition is that the compiler refuses to produce a binary that contains any of these. Not “warns about.” Refuses. The cost is the borrow checker, which forces you to make ownership explicit. The benefit is that an entire class of bugs — the class that historically dominates security incidents — cannot reach production.
// Use-after-free: impossible in safe Rust
fn dangling() -> &str {
let s = String::from("hello");
&s // ERROR: returns a reference to data owned by the current function
}
The error message names the problem directly. There is no version of this code that compiles. You can’t get the runtime bug because you can’t ship the binary.
What’s actually guaranteed
The Rust reference doesn’t use the word “guarantee” lightly. The list of things that cannot happen in safe Rust:
Use-after-free. Once a value is dropped, no reference to it can exist. The borrow checker enforces this by tying every reference to a lifetime no longer than the owner.
Double-free. Drop is automatic and called exactly once. Move semantics make sure no two variables claim ownership of the same allocation.
Data races. A data race needs concurrent access with at least one writer and no synchronization. Rust’s Send/Sync traits, combined with the one-writer-xor-many-readers borrow rule, make this a compile-time error. You can write deadlocks. You can’t write data races.
Buffer overflows on Vec and slice access. Indexing is bounds-checked at runtime; out-of-bounds is a panic, not a wild read. Unchecked access requires unsafe.
Null dereference. There is no null. Optional values are Option<T>, which is an enum the compiler forces you to handle.
Type confusion via aliased mutable references. The aliasing rules prevent this at the type-system level.
// Data race: impossible without unsafe or explicit sync primitives
use std::thread;
fn main() {
let mut counter = 0;
let handles: Vec<_> = (0..4).map(|_| {
thread::spawn(|| {
counter += 1; // ERROR: can't move `counter` into multiple threads
})
}).collect();
}
To make this work you’d need Arc<Mutex<i32>> or AtomicI32. The compiler forces you to make the synchronization choice explicit; you can’t accidentally share unsynchronized state.
What’s NOT guaranteed (and what to do about it)
This is the part most introductions skip. Rust’s safety story is precise — it covers memory safety and data-race freedom — but a lot of things that feel “safe” are not in scope.
Memory leaks. Rc cycles and Box::leak leak by design. The Rust position is that leaks are not a memory safety violation — the memory is still owned by something, it’s just never reached. Long-running services should still profile heap behavior. Tools like heaptrack, valgrind --tool=massif, or bytehound work fine on Rust binaries.
Integer overflow. In debug builds, overflow panics. In release builds, by default, it wraps. This is a deliberate choice for performance, and it bites people who don’t know about it.
fn main() {
let a: u32 = u32::MAX;
let b = a + 1; // debug: panic, release: wraps to 0
println!("{b}");
}
You can opt in to overflow checks in release with overflow-checks = true in Cargo.toml, or use the explicit methods:
[profile.release]
overflow-checks = true
let safe = a.checked_add(1); // Option<u32>
let saturating = a.saturating_add(1); // u32::MAX
let wrapping = a.wrapping_add(1); // 0
For backend code dealing with money, IDs, or sizes, default to checked_* or turn on overflow checks in release. The performance cost is in the noise for most services.
Panics. A panic! unwinds (or aborts) the thread. It’s not undefined behavior, but in a server handling many requests, you don’t want a single bad input to take a worker thread down. Use Result for fallible operations; reserve panics for genuinely unreachable conditions.
Deadlocks. Two tasks waiting on each other’s locks deadlock just like in any other language. No compile-time check for this. Lock ordering discipline still matters.
Logic bugs. Off-by-one in your business logic, wrong condition in a permission check, accidentally returning the wrong field — Rust’s type system helps (use newtypes, exhaustive matching on enums) but doesn’t eliminate. This is what tests are for.
Side-channel leaks. Timing attacks, Spectre-style speculation issues, leaking secrets through error messages — outside the safety story. Use subtle::ConstantTimeEq for cryptographic comparisons, structure error types so they don’t leak data.
The unsafe keyword
The escape hatch. unsafe blocks let you do things the borrow checker can’t verify: dereference raw pointers, call FFI, transmute between types. They’re how you build the abstractions that need to be unsafe internally — Vec, HashMap, Mutex all contain unsafe code.
The discipline that has worked for me: in application code, write zero unsafe. If you genuinely need it, isolate it in a tiny module with a documented invariant and unit tests at the boundary. Most backend services never need unsafe at all.
// If you must use unsafe, document the invariant
/// SAFETY: caller must ensure `ptr` is valid for reads of `len` bytes
/// and that the underlying memory isn't mutated for the duration of the slice.
unsafe fn raw_to_slice<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
std::slice::from_raw_parts(ptr, len)
}
Run cargo geiger periodically to see how much unsafe is in your dependency graph. The honest answer for most production stacks is “some, in the foundational crates, audited.”
What this means for backend services
Concretely, what you stop having to think about:
- The “did this
*psegfault” class of bugs in C-extension code is gone, because there’s no C extension. - You can hand off a buffer between two tasks and the compiler verifies that only one of them has write access at a time.
- Connection pools, channel senders, request contexts — all the data structures that historically had data race bugs in concurrent services — are now compiler-checked.
- Crashes in production drop almost to zero for memory-related reasons. The crashes you get are panics, which are deterministic and reproducible from inputs.
What you still need to think about:
- Same threat model for SQL injection, XSS, CSRF, authn/authz, secret management. Memory safety doesn’t help here.
- Resource exhaustion. A loop that allocates without bound will still OOM. Set limits on request body size, connection counts, queue depth.
- Cryptography. Use audited crates (
ring,rustls,aws-lc-rs). Don’t implement primitives yourself. - Supply chain.
cargo auditagainst the RustSec advisory DB in CI.
Common Pitfalls
The things I’ve seen production teams get wrong about Rust’s safety story:
- Treating it as “secure by default.” Memory safety is one axis. A Rust service can still leak credentials in error responses, accept oversized payloads, or use insecure default TLS settings. Defense in depth still applies.
- Disabling overflow checks without thinking. The default for
--releaseis wrap. Decide consciously, don’t inherit it. - Heavy
unwrap/expectin request paths. Each one is a thread panic on malformed input. Convert toResultat the boundary and propagate. - Using
Rc<RefCell<T>>graphs that cycle. Memory will leak. UseWeakfor back-references in any graph structure. - Ignoring
Send/Syncerrors instead of understanding them. When the compiler says a type isn’tSend, it’s telling you that moving it across threads would violate an invariant. Wrapping inMutexis sometimes right, sometimes a sign you need to restructure.
Wrapping Up
Rust’s memory safety isn’t magic — it’s a specific contract enforced by a specific set of rules, with specific exceptions. The contract is genuinely strong, strong enough that the security and infra teams I work with treat Rust services as a different risk category from C/C++ services. The exceptions are well-defined enough that you can write a checklist for what’s still your problem.
Next post in this series is async Rust with tokio, where the safety guarantees gain a concurrency dimension. If you want to read the formal version of what’s covered here, the Rustonomicon is the canonical reference for the unsafe boundary.