Reference Cycles and Memory Leaks
Understanding reference cycles in Rust, how Rc and RefCell can create memory leaks, and why they do not violate memory safety.
When you combine the shared ownership of Rc<T> with the interior mutability of RefCell<T>, you gain the ability to build complex, mutable, shared data structures. Graphs, trees with parent pointers, doubly-linked lists — all become possible. But this combination also introduces a risk that Rust’s static checks cannot catch: a reference cycle. In a reference cycle, two or more Rc pointers point to each other in a loop, so their reference counts never drop to zero. The values are never dropped and the memory leaks — permanently allocated, unreachable, but completely safe from Rust’s perspective.
Memory leaks are memory-safe:
Rust does not guarantee that no memory leaks occur. Leaks are considered a safe form of resource mismanagement because they never lead to dangling pointers, double frees, or other undefined behavior. The program may waste memory, but it will not corrupt it.
A leak is still a bug in most applications: held memory grows over time, potentially exhausting available RAM. This section digs into exactly how reference cycles happen, what they look like when you monitor reference counts, and the discipline required to avoid them in real code.
How Rc and RefCell Create the Opportunity for Cycles
An Rc<T> pointer manages a heap allocation with two counts: strong_count tracks the number of owning Rc clones, and weak_count tracks non-owning Weak references. The allocation is deallocated only when the strong count reaches zero.
RefCell<T> allows mutation through a shared (&) reference by moving the borrow rules from compile time to runtime. When you place an Rc<T> inside a RefCell<T> — the pattern Rc<RefCell<T>> or RefCell<Rc<T>> — you create a type that can be shared by multiple owners and whose internal connections can be modified after creation. That is exactly what you need to wire up a graph at runtime, but it also means you can write code that makes two nodes point to each other and never let go.
The mental model: each Rc that points to another node keeps that node alive. If you draw a directed graph where each edge is an owning Rc, a cycle in that graph means every node on the cycle has at least one incoming strong reference, and because the nodes inside the cycle all hold strong references to each other, their strong counts will never drop to zero — even after all original variables go out of scope.
Creating a Reference Cycle Step by Step
To see a cycle form and leak, we can build a small cons list whose Cons variant holds an i32 and a RefCell<Rc<List>>. The RefCell lets us change which list the cons cell points to later.
Step 1: Define the list type and create the first node a
We define a recursive enum List with two variants: Cons(i32, RefCell<Rc<List>>) and Nil. Then we create an Rc<List> for node a containing the value 5 and pointing to Nil initially.
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
let a = Rc::new(List::Cons(5, RefCell::new(Rc::new(List::Nil))));
At this point a has a strong count of 1 and its tail is Nil.
Step 2: Create node b that points to a
We create a second Rc<List> node b with value 10, whose tail is a clone of a. Cloning a increments its strong count to 2, because both a and b now own the same List allocation.
let b = Rc::new(List::Cons(10, RefCell::new(Rc::clone(&a))));
After this line, Rc::strong_count(&a) returns 2 and Rc::strong_count(&b) returns 1.
Step 3: Rewire a’s tail to point to b
We use the tail method (defined in the complete example) to get a reference to the RefCell<Rc<List>> inside a. Then we call borrow_mut to replace the inner Rc<List> — currently Nil — with a clone of b. Now a points to b, and b already points to a. The cycle is closed.
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
This increments b's strong count, because a now holds a clone. Both a and b now have a strong count of 2.
Step 4: Observe the reference counts and the leak
After the cycle is formed, the strong counts are:
Rc::strong_count(&a)→ 2Rc::strong_count(&b)→ 2
When main ends, the local variables a and b are dropped, which decrements their respective counts from 2 to 1. Neither count reaches zero, so the heap allocations remain — leaked forever. Trying to print the structure at this point would cause infinite recursion and a stack overflow.
Here is the complete program with tracing println! calls that visualise the counts at each stage:
use std::rc::Rc;
use std::cell::RefCell;
use List::{Cons, Nil};
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see the cycle in action — it will overflow the stack.
// println!("a next item = {:?}", a.tail());
}
Running this program prints:
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2
The counts of 2 are the fingerprint of the cycle. After the variables drop, the strong count for each node is still 1 — the reference each holds to the other. The memory is unreachable but never freed.
Stack overflow on printing:
If you uncomment the final println! macro that prints a.tail(), the Debug formatting tries to follow the chain of Cons variants and encounters an infinite loop. Rust does not detect cycle traversal in Debug output; the program will overflow the stack and crash.
Why the Cycle Leaks
An Rc<T> decrements its strong_count only when a clone is dropped. The cycle means every node on the cycle is a strong reference to the next. There is no external owner left to trigger the decrements. In a non-cyclic graph, the ownership tree eventually ends in a leaf that nobody points to, and counts cascade to zero. A cycle locks the count above zero permanently.
From Rust’s viewpoint, the memory is still “owned” and therefore safe. The borrow checker sees that the data is not accessed after the program’s end, but it cannot statically prove the cycle won’t dissolve later — so it remains silent. This is a logic error, not a violation of Rust’s rules. You must catch it through testing, code review, or by instrumenting Drop implementations to verify that all allocations are freed.
Combining Rc and RefCell requires discipline:
Whenever you nest Rc and RefCell — in either order — you have the power to introduce cycles. Each additional layer of indirection and interior mutability adds complexity. If your data structure models a tree with parent links, using Rc for the parent will create a cycle; you need Weak for the back-reference to break the strong ownership loop.
Consequences in Real Programs
In the example above the program ends immediately after the leak, so the damage is minimal. In a long‑running server or a GUI application, however, a cycle that holds onto large allocations will cause memory usage to grow over time. If the cycle is created repeatedly (e.g., inside a loop or an event handler), the leak accumulates and may eventually cause an out‑of‑memory crash.
Because Rust provides no garbage collection, the operating system reclaims all memory when the process exits. But while the process is alive, leaked memory cannot be reused. For systems with tight memory constraints or long uptimes, even small leaks become a serious problem.
Detecting Reference Cycles
Rust does not ship a built‑in cycle detector for Rc. However, you can write tests that check the strong count before and after a section of code, or implement Drop on your types to log when deallocation happens. If a Drop implementation never fires for an object you believe should have been cleaned up, you likely have a cycle.
Another approach is to deliberately use std::rc::Weak for pointers that do not need to express ownership. If you model your graph such that every cycle contains at least one Weak link, the cycle will break automatically when the last owning Rc is dropped.
The forward path to safe cycles:
Rust provides Weak<T> for exactly this situation. By replacing one strong Rc reference in a cycle with a Weak pointer, you break the ownership loop. The node remains reachable as long as strong references exist elsewhere, but the cycle no longer prevents deallocation.
Summary
Reference cycles are a natural consequence of mixing shared ownership with interior mutability. They leak memory because each node in the cycle holds a strong reference to the next, keeping reference counts above zero even after the program has lost the last external handle. Rust’s safety guarantees treat these leaks as safe — they never cause dangling pointers or undefined behaviour — but they remain a correctness problem in any program that runs long enough for the leak to matter.
The key insight: every Rc clone is a promise to keep the allocation alive. If you wire those promises into a closed loop with no external guard, they hold forever. The solution is to distinguish between owning and non‑owning references using Weak<T>, so you can still walk the graph without trapping memory.