RefCell against Mutex in WebAssembly: November 2026
- Reference machine
- AMD Ryzen 9 5900X 12-Core Processor (x86-64) · Ubuntu 26.04.1 LTS
- Browsers
- Chrome 153 · Firefox 159
- Builds
- panic=abort · panic=unwind · +atomics
- Samples
- 20 rounds per cell
1. Main points
- In the default build, a
Mutexlock took 3× as long as aRefCellborrow in Chrome and 3.3× in Firefox (Figure 2). - With panic=unwind, a
Mutexlock took 1.23× as long as with panic=abort in Chrome and 1.63× in Firefox (Figure 3). - With +atomics, a
Mutexlock took 1.56× as long as without it in Chrome and 2.2× in Firefox (Figure 3).
2. Time per operation
Figure 1. Time per operation in the default build, Chrome 153
Nanoseconds per operation in the default build (panic=abort); for the held/ rows, per loop iteration. The dot is the median of 20 rounds; the line runs from the fastest round to the slowest. Lower is faster. Figure 3 and the results table cover the other two builds.
- Median
- Min–max
Arrow keys move between points; Enter pins one so its values stay shown; Escape clears.
What 3× means in practice
In the default build in Chrome 153, a Mutex lock took 2.148 ns and a RefCell borrow took 0.720 ns. The Mutex is 3× slower, but the gap is only 1.427 ns per lock. A million locks add 1.4 ms, about 8.6% of a 60 fps frame. So the gap matters only when locking is most of what the code does, and even then it shrinks:
- Most of it is the cost of a call.
Mutex::locksets up a stack frame on every call (Figure 4 shows why). When the lock is taken inside the loop's own function (theloop/rows), the compiler sets that frame up once for the whole loop. There, theMutextook 38.8% longer thanRefCell: 0.426 ns more per lock. - A method call is similar. Around a small method call (the
app/rows),Arc<Mutex>took 24.3% longer thanRc<RefCell>. - Lock once, not on every iteration. When the code takes the lock before the loop instead of inside it (the
held/rows), the loop runs as fast as one with no lock at all: 0.433 ns per iteration.
3. Mutex vs RefCell
Figure 2. In the default build, a Mutex lock took 3× as long as a RefCell borrow in Chrome
How many times longer the first operation takes than the second, in Chrome 153. Above 1× means the first is slower. Every round runs both, so each round gives one ratio; the dot is the median of 20 rounds. A ratio counts as a clear difference when it is more than 3% from 1× and its 95% confidence interval (the whisker) does not include 1×.
- A clear difference
- No clear difference
- 95% CI
- Min–max
- ±3% threshold
Arrow keys move between points; Enter pins one so its values stay shown; Escape clears.
4. panic=unwind and +atomics
Figure 3. With panic=unwind, a Mutex lock took 22.5% longer than with panic=abort in Chrome
How many times longer each operation takes in the panic=unwind and +atomics builds than in the default build, in Chrome 153. RefCell should not change between builds, so its row is the control. The WebAssembly is the same in every browser, so a difference between browsers comes from the browser's own compiler. A ratio counts as a clear difference when it is more than 3% from 1× and its 95% confidence interval (the whisker) does not include 1×.
- A clear difference
- No clear difference
- 95% CI
- Min–max
- ±3% threshold
Arrow keys move between points; Enter pins one so its values stay shown; Escape clears.
5. Why the Mutex is slower
Figure 4. A RefCell borrow and a Mutex lock, as WebAssembly
Each operation is a Rust function marked #[inline(never)], shown as wasm-bindgen outputs it: exactly what the browser compiles. The time is the median from the results table for the chosen build. Instruction counts include the panic path, which never runs here.
RefCell::borrow_mut
14 instructions · 0.720 ns in Chrome 153*c.borrow_mut() += x
(func $k_refcell_write (param i32 i64)
block ;; label = @1
local.get 0
i32.load
br_if 0 (;@1;)
local.get 0
local.get 0
i64.load offset=8
local.get 1
i64.add
i64.store offset=8
return
end
i32.const 1049652
call $"core::cell::panic_already_borrowed"
unreachable
)Mutex::lock
42 instructions · 2.148 ns in Chrome 153*m.lock().unwrap() += x
(func $k_mutex_lock (param i32 i64)
(local i32 i32)
global.get $__stack_pointer
i32.const 16
i32.sub
local.tee 2
global.set $__stack_pointer
local.get 0
i32.load8_u
local.set 3
local.get 0
i32.const 1
i32.store8
local.get 2
local.get 3
i32.store8 offset=15
block ;; label = @1
local.get 3
i32.const 1
i32.ne
br_if 0 (;@1;)
i32.const 0
local.get 2
i32.const 15
i32.add
i32.const 1049579
i32.const 1050308
i32.const 65
i32.const 1050340
call $"core::panicking::assert_failed::<bool, bool>"
unreachable
end
local.get 0
i32.const 0
i32.store8
local.get 0
local.get 0
i64.load offset=8
local.get 1
i64.add
i64.store offset=8
local.get 2
i32.const 16
i32.add
global.set $__stack_pointer
)Source: The modules this page runs, printed by wasm-tools 1.262.0. Crate hashes are removed.
What the code shows
- panic=abort (the default). The
RefCellborrow is one load and one branch. In this function, LLVM can see that nothing reads the borrow flag while it is set, so it never writes it. TheMutexsets and clears its lock flag, and checks it with anassert_eq!. The panic path of that assert needs a stack frame, so the function also moves the stack pointer down and back up on every call.Mutex::try_lockhas no assert and so no stack frame, and it takes less time thanMutex::lock: the frame is a large part of theMutex's extra cost.RwLock::writecompiles to almost the same code as theRefCellborrow, so it costs about the same. - panic=unwind. The
RefCellborrow does not change: aRefCellcan't be poisoned. TheMutexnow also checks its poison flag, and reads the global panic count twice, once to lock and once to unlock.RwLock::writenow writes its lock state, checks poison and needs a stack frame too, so it loses that advantage (see Figure 3). - +atomics. The
Mutexbecomes a futex lock built on atomic instructions: a compare-and-swap to lock and a swap to unlock. The code that waits for another thread is there but never runs, because only one thread uses the lock. ThisMutexhas fewer instructions than the default one but is slower: an atomic read-modify-write costs much more than a plain load and store, even with one thread. The same is true of the relaxed atomic add in Figure 3. TheRefCellborrow does not change.
6. Results
| Workload | panic=abort | panic=unwind | +atomics |
|---|---|---|---|
| One call per operation | |||
op/plain*c += x | 0.646 nsbaseline | 0.647 nsbaseline | 0.648 nsbaseline |
op/cellc.set(c.get() + x) | 0.654 ns1.01× op/plain | 0.647 ns1.00× op/plain | 0.648 ns1.00× op/plain |
op/atomic_relaxedc.fetch_add(x, Relaxed) | 0.650 ns1.00× op/plain | 0.649 ns1.00× op/plain | 1.948 ns2.98× op/plain |
op/refcell_write*c.borrow_mut() += x | 0.720 ns1.11× op/plain | 0.724 ns1.11× op/plain | 0.720 ns1.11× op/plain |
op/refcell_try_writeif let Ok(mut g) = c.try_borrow_mut() { *g += x } | 0.720 ns1.00× op/refcell_write | 0.721 ns1.00× op/refcell_write | 0.719 ns1.00× op/refcell_write |
op/mutex*m.lock().unwrap() += x | 2.148 ns3.31× op/plain | 2.625 ns4.03× op/plain | 3.332 ns5.13× op/plain |
op/mutex_tryif let Ok(mut g) = m.try_lock() { *g += x } | 1.288 ns0.60× op/mutex | 1.492 ns0.57× op/mutex | 3.406 ns1.02× op/mutex |
op/rwlock_write*l.write().unwrap() += x | 0.728 ns1.11× op/plain | 1.967 ns3.04× op/plain | 3.898 ns6.00× op/plain |
op/plain_read*c | 0.288 nsbaseline | 0.287 nsbaseline | 0.289 nsbaseline |
op/refcell_read*c.borrow() | 0.469 ns1.63× op/plain_read | 0.468 ns1.63× op/plain_read | 0.467 ns1.63× op/plain_read |
op/rwlock_read*l.read().unwrap() | 0.542 ns1.89× op/plain_read | 0.749 ns2.58× op/plain_read | 3.998 ns13.88× op/plain_read |
| Lock inside a loop | |||
loop/plainv += x; *p = v | 0.433 nsbaseline | 0.435 nsbaseline | 0.437 nsbaseline |
loop/cellp.set(p.get() + x) | 0.433 ns1.00× loop/plain | 0.432 ns1.00× loop/plain | 0.438 ns1.00× loop/plain |
loop/atomic_relaxedp.fetch_add(x, Relaxed) | 0.510 ns1.18× loop/plain | 0.517 ns1.18× loop/plain | 1.955 ns4.46× loop/plain |
loop/refcell_write*p.borrow_mut() += x | 1.108 ns2.55× loop/plain | 1.118 ns2.56× loop/plain | 1.114 ns2.56× loop/plain |
loop/mutex*p.lock().unwrap() += x | 1.534 ns3.55× loop/plain | 1.806 ns4.14× loop/plain | 3.454 ns7.92× loop/plain |
loop/rwlock_write*p.write().unwrap() += x | 1.185 ns2.73× loop/plain | 1.612 ns3.68× loop/plain | 3.916 ns8.95× loop/plain |
| Lock held for the whole loop | |||
held/refcelllet mut g = p.borrow_mut(); for … { *g += x } | 0.433 ns1.00× loop/plain | 0.437 ns1.00× loop/plain | 0.435 ns1.00× loop/plain |
held/mutexlet mut g = p.lock().unwrap(); for … { *g += x } | 0.434 ns1.00× loop/plain | 0.434 ns1.00× loop/plain | 0.436 ns1.00× loop/plain |
| Shared object with a method call | |||
app/rc_refcells.borrow_mut().record(x) | 1.307 nsbaseline | 1.314 nsbaseline | 1.306 nsbaseline |
app/arc_mutexs.lock().unwrap().record(x) | 1.623 ns1.24× app/rc_refcell | 1.907 ns1.46× app/rc_refcell | 3.390 ns2.59× app/rc_refcell |
4096 iterations per call; panic=abort, panic=unwind and +atomics measured in the same rounds.
Measurement notes
- Conditions
- Chrome 153.0.8010.12 (headless) on Ubuntu 26.04.1 LTS; AMD Ryzen 9 5900X 12-Core Processor, x86_64, 12 logical cores; on AC power, Low Power Mode off. Captured 11 November 2026, 18:34–18:36 UTC. Capture 1 of 2.
- Clock
- Cross-origin isolated. Timer resolution in a worker 5 µs (median step; finest 5 µs).
- Method
- 20 samples per row, each a batch of at least 50 ms divided by its repetitions. Untimed warm-up of at least 250 ms and 2 rounds (at most 20). Order rotated by one each round; ratios are paired by round.
- Workloads
op/: Each iteration calls a small function marked#[inline(never)], so LLVM keeps it as a call (the browser can still inline it). Writes are compared withop/plain, reads withop/plain_read, and eachtry_row with the same lock taken the normal way.loop/: The lock is taken on every iteration, in the same function as the loop, so LLVM can optimize across it. The value comes from outside the function, like real shared state, so LLVM can't remove the lock. Each iteration's input goes throughblack_box, which could read the lock, so here evenRefCellwrites its borrow flag on every iteration.held/: The same loop, but the lock is taken once before the loop instead of on every iteration.app/:Rc<RefCell<State>>vsArc<Mutex<State>>, calling a small method, kept as a call, that bumps one of two counters and stores the last value: closer to real application code.
- Builds
- panic=abort: The default for
wasm32-unknown-unknownand WASI. A panic can never be caught, soMutexhas no poison flag: it is a singleCell<bool>. The module reportspanic=abort atomics=false. - panic=unwind: std rebuilt so that panics unwind. Poisoning comes back: every
Mutexlock andRwLockwrite checks the poison flag and the global panic count, and anRwLockread checks the poison flag. The module reportspanic=unwind atomics=false. - +atomics: std rebuilt with thread support (atomics and shared memory).
MutexandRwLockbecome futex locks, which can put a waiting thread to sleep, though only one thread ever runs here. Atomic types such asAtomicU64become real atomic instructions too; without +atomics they compile to plain loads and stores. The module reportspanic=abort atomics=true. - All three use rustc nightly-2026-11-01, std built from source, opt-level 3, LTO true, no wasm-opt. wasm-opt is skipped because Binaryen does its own inlining and folding, which would hide what LLVM generated for each lock.
- panic=abort: The default for
- Design
- The data lives on the heap for the whole run, so the optimizer can't delete a lock just because only the timed function can see it. Reads are compared with a plain read, not with a read-modify-write. There is no sequentially consistent (
Ordering::SeqCst) atomic row: no build here compiles it any differently from the relaxed one. - Checks
- Before timing, every workload runs once in every build on fresh data. If any build gets a different result, the run stops before it times anything.
- Browsers inline
#[inline(never)]only applies to LLVM. The browser can still inline the function into its loop. Chrome's engine, V8, does; this page does not check whether Firefox does. So theop/rows measure the operation as the browser runs it, not the cost of a call.- Firefox 159 capture
- Firefox 159.0.1 (headless), captured 11 November 2026, capture 1 of 2. Raw per-round data (JSON)
- Build
- rustc nightly-2026-11-01, std built from source for each build; wasm-bindgen 0.2.130. Release profile: opt-level 3, LTO true, codegen-units 1; no wasm-opt. Builds, above, gives each build's own flags.
- Commit
511885ab6a4e- Artifacts
3 wasm modules, SHA-256
locks_abort_bg.wasm39.6 kB0242fb0a63cce2915e91d20a59eadc2979222c9f9d0e6f9c303989f74a46a4b7locks_atomics_bg.wasm38.1 kBe79e4fb10b41a9864ea63306c45ff0fd798ead3c66afef2dbcb568efef8b429alocks_unwind_bg.wasm52.2 kBe14887cdef9c8352646e2ab711a51ed681fc8bcb67745e11de207e4095531154
- Raw data
- Raw per-round data (JSON)
Reference edition 2026-11-11 ·AMD Ryzen 9 5900X 12-Core Processor (x86_64), Ubuntu 26.04.1 LTS ·Chrome 153 and Firefox 159 ·20 rounds per cell ·Method ·Raw data (JSON)
7. Measure on this machine
Run every workload in every build this browser can load. A run takes about a minute at 10 rounds. Keep this tab in the foreground: the run pauses while it is in the background.