Skip to content
>_rs4ts

Interior Mutability: RefCell<T> and Mutex<T>

19 min readupdated byAhmet ZeybekAhmet Zeybek· Jul 10, 2026

In TypeScript every object is freely mutable through any reference you hold — aliasing and mutation coexist without comment. Rust forbids that at compile time: you may have many shared & references or one exclusive &mut, never both. Interior mutability is the escape hatch that lets you mutate data through a shared reference anyway, by moving the “one writer, many readers” check from compile time to runtime. RefCell<T> does this on a single thread; Mutex<T> does it across threads.


RefCell<T> and Mutex<T> both let you mutate data you only hold a shared reference to (&self rather than &mut self). They enforce the same rule the borrow checker enforces statically — no aliased mutation — but they enforce it dynamically. RefCell checks on the current thread and panics if you break the rule; Mutex is thread-safe and blocks until the data is free. For a TypeScript developer the mental model is: these types give you back the casual “mutate through any handle” feeling of JavaScript objects, but with a guardrail that fails loudly instead of silently corrupting data.

Note: This file covers the two checked interior-mutability containers. Its single-threaded sibling for Copy values, Cell<T>, needs no runtime flag at all. For sharing the same data between owners (the thing you almost always combine RefCell/Mutex with) see Rc<T> / Arc<T>. When you cannot decide which container to reach for, jump to the decision guide.


In TypeScript, a shared mutable object is the default. Here a MetricsCollector is handed to several subsystems; each holds the same object and mutates it freely. Nothing in the type system stops two code paths from writing at “the same time.” JavaScript’s single-threaded event loop makes that mostly safe, and TypeScript never asks you to think about it.

// TypeScript/JavaScript - shared mutable state is the default
class MetricsCollector {
private counts = new Map<string, number>();
// `increment` mutates internal state, but callers never see a difference
// between a "read" method and a "write" method — both just take `this`.
increment(event: string): void {
this.counts.set(event, (this.counts.get(event) ?? 0) + 1);
}
total(): number {
let sum = 0;
for (const n of this.counts.values()) sum += n;
return sum;
}
}
const metrics = new MetricsCollector();
// Two unrelated subsystems share the SAME collector via aliasing.
const auth = metrics;
const api = metrics;
auth.increment("login");
api.increment("request");
api.increment("request");
console.log(metrics.total()); // 3

Under Node v22 this prints 3. Note three things a Rust developer will care about: the two aliases (auth, api) point at one object, increment mutates through a plain shared reference, and nobody had to declare the object “mutable.”


The naive port fails: if record takes &self, you cannot assign to self.hits. Wrapping the field in RefCell<T> restores the ability to mutate through a shared reference: borrow_mut() hands you a temporary exclusive handle, checked at runtime.

use std::cell::RefCell;
#[derive(Debug)]
struct Counter {
hits: RefCell<u32>,
}
impl Counter {
fn new() -> Self {
Counter { hits: RefCell::new(0) }
}
// Note: &self, NOT &mut self — the mutation happens "inside" the cell.
fn record(&self) {
*self.hits.borrow_mut() += 1;
}
fn total(&self) -> u32 {
*self.hits.borrow()
}
}
fn main() {
let counter = Counter::new();
counter.record();
counter.record();
counter.record();
println!("total hits = {}", counter.total());
}

Running it prints:

total hits = 3

The signature fn record(&self) is the whole point. The method takes a shared reference, yet mutates, exactly the TypeScript ergonomics, but the mutation is funneled through RefCell’s runtime check rather than being a free-for-all.


Why the compiler refuses plain mutation through &self

Section titled “Why the compiler refuses plain mutation through &self”

Rust’s borrowing rules are not a style guide; they are how the language guarantees memory safety without a garbage collector (see Section 05 — Ownership). The rule: at any instant a value has either one &mut reference or any number of & references. A method taking &self only has a shared reference, so writing to a field is rejected:

struct Logger {
count: u32, // plain field, no interior mutability
}
impl Logger {
fn log(&self, msg: &str) {
println!("{msg}");
self.count += 1; // does not compile (error[E0594]): cannot assign through &self
}
}
fn main() {
let logger = Logger { count: 0 };
logger.log("hi");
}

The real compiler output:

error[E0594]: cannot assign to `self.count`, which is behind a `&` reference
--> src/main.rs:8:9
|
8 | self.count += 1; // does not compile (error[E0594]): cannot assign through &self
| ^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written
|
help: consider changing this to be a mutable reference
|
6 | fn log(&mut self, msg: &str) {
| +++

Sometimes &mut self is the right fix. But it is contagious: every caller now needs exclusive access, which is impossible the moment the value is shared (behind an Rc, stored in a Vec you are iterating, captured by two closures). RefCell is the answer when you need the mutation but cannot get exclusive access at compile time.

What borrow and borrow_mut actually return

Section titled “What borrow and borrow_mut actually return”

RefCell<T> is a struct holding your T plus a small integer “borrow flag.” Two methods read and update that flag:

  • borrow() -> Ref<'_, T>: registers a shared borrow and returns a smart-pointer guard that derefs to &T.
  • borrow_mut() -> RefMut<'_, T>: registers an exclusive borrow and returns a guard that derefs to &mut T.

The guard’s Drop releases the borrow. So the flag tracks “how many Refs and whether a RefMut is alive” exactly the way the compiler’s static analysis would, just at runtime. The *self.hits.borrow_mut() += 1 line creates a RefMut, dereferences it to a &mut u32, increments, and drops the guard at the end of the statement (the temporary’s lifetime ends at the ;).

If you violate the one-writer rule, RefCell does not return an error by default; it panics, the same way an out-of-bounds array index does. Holding a read borrow and then asking for a write borrow:

use std::cell::RefCell;
fn main() {
let cell = RefCell::new(vec![1, 2, 3]);
let reader = cell.borrow(); // Ref<Vec<i32>>, a shared read borrow
println!("len is {}", reader.len());
cell.borrow_mut().push(4); // panics: reader is still alive
}

The real output:

len is 3
thread 'main' panicked at src/main.rs:8:10:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

reader is still in scope when borrow_mut() runs, so the borrow flag says “a shared borrow is active” and the write request panics. The fix is to end the read borrow first: drop reader or scope it in a { } block.

RefCell is not thread-safe (it is !Sync), so the compiler will not let you share one across threads. When you need interior mutability across threads, reach for std::sync::Mutex<T>. It enforces the identical rule — one writer at a time — but instead of panicking when the data is busy, lock() blocks the calling thread until the lock is free.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for _ in 0..8 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
for _ in 0..1000 {
// lock() blocks until the mutex is free, returns a guard.
let mut n = counter.lock().unwrap();
*n += 1;
} // guard dropped here -> lock released each iteration
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
println!("final count = {}", *counter.lock().unwrap());
}

Running it prints:

final count = 8000

Eight threads each increment 1000 times; the Mutex serializes every *n += 1, so the result is exactly 8000 with no lost updates. The Arc (atomic reference count) is what lets all eight threads own the same mutex. Mutex provides the mutation, Arc provides the sharing. This Arc<Mutex<T>> pair is to multithreaded Rust what a plain shared object is to JavaScript.

Tip: lock() returns a Result because the mutex can be poisoned (see Pitfalls). In application code, .unwrap() (or .expect("lock poisoned")) is the common, accepted choice: a poisoned lock usually means another thread already crashed.

Holding the guard for as little time as possible

Section titled “Holding the guard for as little time as possible”

A MutexGuard (and a RefMut) keeps the lock until it is dropped. Wrapping the locked work in a block releases it early, before any expensive non-locked work runs:

use std::sync::Mutex;
fn main() {
let data = Mutex::new(vec![10, 20, 30]);
let sum: i32 = {
let guard = data.lock().unwrap();
guard.iter().sum()
}; // guard dropped here, before the println formatting below
println!("sum = {}", sum);
}

Output:

sum = 60

When you do have exclusive access, skip the lock

Section titled “When you do have exclusive access, skip the lock”

If you own a Mutex<T> by &mut (or by value), the compiler has already proven no other thread can touch it, so locking is pure overhead. get_mut() (borrow) and into_inner() (by value) reach the data without any locking:

use std::sync::Mutex;
fn main() {
let mut m = Mutex::new(0);
// We own `m` mutably, so the compiler proves no other thread can race.
*m.get_mut().unwrap() += 100;
println!("value = {}", m.into_inner().unwrap());
}

Output:

value = 100

This mirrors RefCell::get_mut, and it is a good signal of intent: it tells the reader “no contention is possible here.”


ConceptTypeScript / JavaScriptRefCell<T> (Rust)Mutex<T> (Rust)
Mutate via shared handleAlways allowedAllowed, runtime-checkedAllowed, runtime-checked
Aliased mutation ruleNone enforcedOne writer XOR many readersOne writer at a time
When the rule is brokenPossible silent bugPanics immediately(Cannot — it blocks)
Thread-safe?N/A (single event loop)No (!Sync)Yes (Send + Sync)
Cost when uncontendedn/aA few integer opsAn atomic compare-and-swap
Get exclusive access cheaplyn/aget_mut()get_mut(), into_inner()
Typical pairing for sharingjust alias the objectRc<RefCell<T>>Arc<Mutex<T>>

A few conceptual contrasts worth internalizing:

  • Compile-time vs runtime. Rust’s default borrow checking is static and free. RefCell and Mutex opt into a runtime check, paying a little performance and (for RefCell) the risk of a panic, in exchange for flexibility the static checker cannot express.
  • Panic vs block. This is the headline difference between the two types. A second borrow_mut on a busy RefCell is a bug and panics. A second lock on a busy Mutex is normal contention and waits. They feel similar but model opposite situations.
  • Mutex<T> wraps the data, it is not a separate object. Unlike a Java synchronized block or a free-standing lock variable, a Rust Mutex contains the data it protects. You literally cannot read the value without locking: the type system makes “forgot to take the lock” impossible.
  • Unlike TypeScript, sharing and mutating are separate concerns with separate tools. JavaScript gives you both in one move (alias an object). Rust makes you pick a sharer (Rc/Arc) and a mutator (RefCell/Mutex) and compose them.

Pitfall 1: Two live borrow_muts on the same thread

Section titled “Pitfall 1: Two live borrow_muts on the same thread”

The single most common RefCell mistake, taking a second mutable borrow while the first is alive:

use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
let mut first = cell.borrow_mut();
let mut second = cell.borrow_mut(); // panics: already mutably borrowed
*first += 1;
*second += 1;
}

Real output:

thread 'main' panicked at src/main.rs:7:27:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The opposite ordering (a read while a write is held) panics with a slightly different message — already mutably borrowed: BorrowError — but it is the same class of bug. Fix: never hold two borrows that overlap. Finish with the first guard (let it drop, or scope it) before taking the second, and prefer one short borrow_mut() per statement over long-lived guard variables.

Pitfall 2: Trying to send a RefCell across threads

Section titled “Pitfall 2: Trying to send a RefCell across threads”

A RefCell looks like it should work in a thread, but it is !Sync and the compiler rejects sharing it: exactly the protection that pushes you toward Mutex:

use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let shared = Arc::new(RefCell::new(0));
let s = Arc::clone(&shared);
let handle = thread::spawn(move || {
*s.borrow_mut() += 1; // does not compile: RefCell is not Sync
});
handle.join().unwrap();
println!("{}", shared.borrow());
}

The real error (abridged):

error[E0277]: `RefCell<i32>` cannot be shared between threads safely
--> src/main.rs:9:32
|
9 | let handle = thread::spawn(move || {
| __________________-------------_^
...
| |_____^ `RefCell<i32>` cannot be shared between threads safely
|
= help: the trait `Sync` is not implemented for `RefCell<i32>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
= note: required for `Arc<RefCell<i32>>` to implement `Send`

Fix: swap RefCell for Mutex (or RwLock if reads vastly outnumber writes) and Rc for Arc: Arc<Mutex<i32>>. The compiler error is doing you a favor: it caught a data race before it could exist.

Pitfall 3: Locking the same Mutex twice on one thread (deadlock)

Section titled “Pitfall 3: Locking the same Mutex twice on one thread (deadlock)”

Because lock() blocks rather than panicking, locking a non-reentrant Mutex you already hold on the same thread deadlocks the thread forever:

// Conceptual — do NOT run; this hangs:
// let m = Mutex::new(0);
// let g1 = m.lock().unwrap();
// let g2 = m.lock().unwrap(); // thread blocks forever waiting for itself

There is no compiler error and no panic: the program simply hangs. Fix: never call lock() while you still hold a guard from the same mutex. Keep critical sections small, and if a helper needs the data, pass it &mut T from the already-acquired guard rather than re-locking.

Warning: Rust’s Mutex is not reentrant. Unlike some languages’ recursive locks, taking the same lock twice on one thread is undefined-time hang, not a no-op.

Pitfall 4: Forgetting that a long-lived guard blocks others

Section titled “Pitfall 4: Forgetting that a long-lived guard blocks others”

A MutexGuard held across an .await or a slow I/O call keeps every other thread (or task) waiting. In synchronous code this just hurts throughput; in async code it can stall an entire runtime (std::sync::Mutex should generally not be held across .await; see Section 11 — Async). Fix: copy or move what you need out of the guard, drop it, then do the slow work.


  • Reach for the borrow checker first. Interior mutability is a deliberate exception, not a default. If &mut self or restructuring the data threads cleanly, prefer it: you keep compile-time guarantees and zero runtime cost.

  • Keep borrows and locks short. One borrow_mut()/lock() per statement when you can. Scope guards in { } blocks so they drop early. Long-lived guards are how both panics (RefCell) and deadlocks (Mutex) happen.

  • Pick the container by thread-boundary, not by habit. Single thread → RefCell (cheaper, no atomics). Crossing threads → Mutex (or RwLock for read-heavy workloads). The compiler enforces this for you via Send/Sync.

  • Compose intentionally: Rc<RefCell<T>> for a shared, mutable, single-threaded graph; Arc<Mutex<T>> for the multithreaded version. Document why the shared mutability is needed.

  • Use try_borrow_mut() / try_lock() when contention is expected and recoverable. They return a Result instead of panicking/blocking, so you can fall back gracefully:

    use std::cell::RefCell;
    fn main() {
    let cell = RefCell::new(String::from("hello"));
    let _read = cell.borrow();
    match cell.try_borrow_mut() {
    Ok(mut w) => w.push_str(" world"),
    Err(_) => println!("already borrowed; skipping mutation"),
    }
    }

    Output: already borrowed; skipping mutation.

  • Prefer the unlocked accessors when you own the data. get_mut() and into_inner() skip the runtime check entirely and signal “no contention here” to the next reader of your code.


A memoizing cache is interior mutability’s sweet spot: the interface is a read-only “look up a value,” but the implementation must mutate a cache on a miss. With RefCell the public get method takes &self, so the memoizer composes like any other read-only service. Callers never need &mut.

use std::cell::RefCell;
use std::collections::HashMap;
/// A memoizing wrapper around an expensive pure function.
/// The cache mutates on a miss, yet `get` takes `&self` — callers
/// never need a `&mut Memoizer`, so the type composes like a read-only service.
struct Memoizer<F> {
compute: F,
cache: RefCell<HashMap<u64, u64>>,
calls: RefCell<u64>, // how many times we actually ran `compute`
}
impl<F: Fn(u64) -> u64> Memoizer<F> {
fn new(compute: F) -> Self {
Memoizer {
compute,
cache: RefCell::new(HashMap::new()),
calls: RefCell::new(0),
}
}
fn get(&self, key: u64) -> u64 {
// Fast path: check the cache, and release the borrow before recomputing.
if let Some(&hit) = self.cache.borrow().get(&key) {
return hit;
}
// Miss: no borrow of `cache` is held while we run the real computation.
*self.calls.borrow_mut() += 1;
let value = (self.compute)(key);
self.cache.borrow_mut().insert(key, value);
value
}
fn compute_calls(&self) -> u64 {
*self.calls.borrow()
}
}
fn main() {
// Pretend this is an expensive call (DB hit, hashing, etc.).
let memo = Memoizer::new(|n: u64| n * n);
let inputs = [4, 4, 7, 4, 7, 9];
let results: Vec<u64> = inputs.iter().map(|&n| memo.get(n)).collect();
println!("results = {results:?}");
println!("inputs = {inputs:?}");
println!(
"compute calls = {} (vs {} lookups)",
memo.compute_calls(),
inputs.len()
);
}

Running it prints:

results = [16, 16, 49, 16, 49, 81]
inputs = [4, 4, 7, 4, 7, 9]
compute calls = 3 (vs 6 lookups)

Six lookups, only three real computations: the three distinct inputs 4, 7, 9. The important detail is in get: the if let Some(&hit) = self.cache.borrow()... borrow ends at the end of that if’s condition expression, so when we later call self.cache.borrow_mut().insert(...) there is no overlapping borrow to panic on. Sequencing borrows like this — read, drop, then write — is the discipline that keeps RefCell panic-free.

Note: To make this cache thread-safe, change both RefCell to Mutex, wrap the whole Memoizer in an Arc, and lock instead of borrow. The structure is identical; only the runtime-check strategy changes.



Exercise 1: A rate limiter that counts down through &self

Section titled “Exercise 1: A rate limiter that counts down through &self”

Difficulty: Easy

Objective: Get comfortable mutating state behind a shared reference with RefCell.

Instructions: Implement a RateLimiter with a try_acquire(&self) -> bool method. It starts with a quota and returns true (consuming one unit) while units remain, and false once exhausted. Note that try_acquire must take &self, not &mut self.

use std::cell::RefCell;
struct RateLimiter {
remaining: RefCell<u32>,
}
impl RateLimiter {
fn new(quota: u32) -> Self {
/* ??? */
}
fn try_acquire(&self) -> bool {
/* ??? */
}
}
fn main() {
let limiter = RateLimiter::new(2);
// Expected: true true false
println!("{} {} {}", limiter.try_acquire(), limiter.try_acquire(), limiter.try_acquire());
}
Solution
use std::cell::RefCell;
struct RateLimiter {
remaining: RefCell<u32>,
}
impl RateLimiter {
fn new(quota: u32) -> Self {
RateLimiter { remaining: RefCell::new(quota) }
}
fn try_acquire(&self) -> bool {
let mut r = self.remaining.borrow_mut();
if *r == 0 {
false
} else {
*r -= 1;
true
}
}
}
fn main() {
let limiter = RateLimiter::new(2);
println!(
"{} {} {}",
limiter.try_acquire(),
limiter.try_acquire(),
limiter.try_acquire()
);
}

Output:

true true false

The single borrow_mut() at the top of try_acquire is dropped when the method returns, so consecutive calls never overlap and never panic.

Exercise 2: A shared event log written by two closures

Section titled “Exercise 2: A shared event log written by two closures”

Difficulty: Medium

Objective: Combine Rc (sharing) with RefCell (mutation): the single-threaded shared-mutable-state idiom.

Instructions: Build a Vec<String> event log that two independent logging closures both append to. Each closure should tag its messages with a name (e.g. "auth", "db"). After running a few events, print the whole log in order. Hint: the log’s type is Rc<RefCell<Vec<String>>>, and each closure should hold its own Rc::clone.

Solution
use std::cell::RefCell;
use std::rc::Rc;
type Log = Rc<RefCell<Vec<String>>>;
fn make_logger(log: &Log, name: &'static str) -> impl Fn(&str) {
let log = Rc::clone(log);
move |msg: &str| log.borrow_mut().push(format!("{name}: {msg}"))
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let auth = make_logger(&log, "auth");
let db = make_logger(&log, "db");
auth("login ok");
db("query 1");
auth("logout");
for line in log.borrow().iter() {
println!("{line}");
}
}

Output:

auth: login ok
db: query 1
auth: logout

Each make_logger call takes its own Rc::clone, so both closures own the same heap-allocated RefCell<Vec<String>>. The borrow_mut() inside each closure is short-lived (one push then dropped), so the appends never collide.

Difficulty: Hard

Objective: Convert a single-threaded interior-mutability pattern to a thread-safe one — RefCellMutex, RcArc — and have several threads write to a shared collection.

Instructions: Spawn four threads, each of which pushes a value (id * 10) into a shared Vec<u64>. Join all threads, then print the collected values sorted ascending. You should reach for Arc<Mutex<Vec<u64>>>. (Why not Rc<RefCell<…>>? Try it and read the compiler error — it is Pitfall 2.)

Solution
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let registry: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
for id in 0..4u64 {
let registry = Arc::clone(&registry);
handles.push(thread::spawn(move || {
registry.lock().unwrap().push(id * 10);
}));
}
for h in handles {
h.join().unwrap();
}
let mut final_ids = registry.lock().unwrap().clone();
final_ids.sort_unstable();
println!("{final_ids:?}");
}

Output:

[0, 10, 20, 30]

Each thread takes its own Arc::clone (cheap reference-count bump, see Rc<T>/Arc<T>) so all four own the same Mutex. The lock() serializes the four push calls, so no update is lost. We sort at the end because thread completion order — and therefore insertion order — is nondeterministic.