Pooling Fixed-Size Objects in Wasm
This page answers one task: a WebAssembly module creates and destroys the same kind of object constantly — particles, entities, network messages, undo records, tree nodes — and the general-purpose allocator shows up in profiles and fragments memory. You want a pool that recycles fixed-size objects cheaply, safely, and in a way JavaScript can reference.
Prerequisites
- [ ] A module (Rust or C/C++) with a hot allocate/free pattern for one struct type.
- [ ] A profile or allocation count showing the cost.
- [ ] A benchmark that reproduces the churn.
Why pools help in WebAssembly
A general-purpose allocator handles any size, which means bookkeeping on every allocation and free: finding a fitting block, splitting and merging, updating headers. For a struct allocated and freed millions of times, that bookkeeping dominates. A pool for one fixed size is much simpler: freed slots go onto a free list, and allocation takes the first slot from the list — a couple of memory operations. Because every slot is the same size, there is no fragmentation within the pool, and the objects sit contiguously in memory, which helps cache locality when iterating over them.
In WebAssembly there is an extra benefit: linear memory never shrinks, so fragmentation from churn is permanent until the instance is recreated. A pool caps the memory used for its object type at the peak number of live objects, and keeps that memory reusable.
Step 1 — store objects in a Vec and hand out indices
In Rust, the simplest pool is a Vec of slots plus a free list of indices. Handing out indices rather than references avoids borrow-checker fights and,
importantly for the boundary, gives JavaScript a plain number to hold:
pub struct Pool<T> {
slots: Vec<Slot<T>>,
free: Vec<u32>,
}
struct Slot<T> { generation: u32, value: Option<T> }
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Handle { index: u32, generation: u32 }
impl<T> Pool<T> {
pub fn with_capacity(n: usize) -> Self {
Self { slots: Vec::with_capacity(n), free: Vec::with_capacity(n) }
}
pub fn insert(&mut self, value: T) -> Handle {
if let Some(index) = self.free.pop() {
let slot = &mut self.slots[index as usize];
slot.value = Some(value);
Handle { index, generation: slot.generation }
} else {
self.slots.push(Slot { generation: 0, value: Some(value) });
Handle { index: self.slots.len() as u32 - 1, generation: 0 }
}
}
pub fn remove(&mut self, h: Handle) -> Option<T> {
let slot = self.slots.get_mut(h.index as usize)?;
if slot.generation != h.generation { return None; }
slot.generation = slot.generation.wrapping_add(1);
self.free.push(h.index);
slot.value.take()
}
pub fn get(&self, h: Handle) -> Option<&T> {
let slot = self.slots.get(h.index as usize)?;
(slot.generation == h.generation).then(|| slot.value.as_ref()).flatten()
}
}
Crates such as slab, slotmap and generational-arena implement this pattern with more features; use one unless you need something specific.
Step 2 — use generations to catch stale handles
The generation counter is what makes index handles safe. When a slot is freed, its generation increments; a handle kept from before the free carries
the old generation and no longer matches, so get returns None instead of returning whatever object now occupies the slot. Without generations, a stale
index silently refers to a different object — a classic use-after-free bug that, in Wasm, corrupts logic rather than crashing. Generations make it a
detectable error.
Step 3 — expose handles to JavaScript as numbers
JavaScript holds handles as numbers. Pack index and generation into one f64-safe integer (or two u32s) and validate on every call:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct World { particles: Pool<Particle> }
#[wasm_bindgen]
impl World {
pub fn spawn(&mut self, x: f32, y: f32) -> f64 {
let h = self.particles.insert(Particle::new(x, y));
((h.generation as u64) << 32 | h.index as u64) as f64
}
pub fn despawn(&mut self, id: f64) -> bool {
self.particles.remove(unpack(id)).is_some()
}
}
fn unpack(id: f64) -> Handle {
let v = id as u64;
Handle { index: v as u32, generation: (v >> 32) as u32 }
}
This avoids exporting each particle as a wasm-bindgen class, which would create a JavaScript wrapper object and require free() per particle — the
very churn the pool removes.
Step 4 — iterate over live objects efficiently
Pools make bulk processing fast: iterate over the slot array and skip free slots. For dense pools, that is close to iterating a plain array. For sparse pools — many free slots after a burst — keep a separate dense array of live indices, or compact periodically. If JavaScript needs to read many objects at once (for rendering), expose a typed-array view of a packed array of the relevant fields rather than calling per object.
Step 5 — measure the gain
Compare the pooled version against the allocator-based version under the same churn: time per frame or per operation, allocation count, and memory after a long run. Pools typically cut allocation time to near zero for the pooled type and keep memory flat; if the gain is small, the allocator was not the bottleneck, and the pool is adding complexity for nothing.
Pools in C and C++
The same structure works in C: an array of slots, a free-list head, and slot reuse. A common trick is to store the next-free index inside the free slot itself, so no separate free list is needed:
typedef union { Particle p; uint32_t next_free; } Slot;
static Slot slots[MAX_PARTICLES];
static uint32_t free_head = 0;
void pool_init(void) { for (uint32_t i = 0; i < MAX_PARTICLES; i++) slots[i].next_free = i + 1; }
Particle* pool_alloc(void) {
if (free_head >= MAX_PARTICLES) return NULL;
Slot* s = &slots[free_head]; free_head = s->next_free; return &s->p;
}
void pool_free(Particle* p) {
Slot* s = (Slot*)p; s->next_free = free_head; free_head = (uint32_t)(s - slots);
}
A fixed maximum makes memory use predictable and the pool lives in static data, but exhausting it must be handled: return an error, or grow by adding another block of slots.
Sizing and growth
Pools can be fixed-size or growable. Fixed pools give predictable memory and fail clearly at the limit, which suits games and simulations with known
maxima. Growable pools based on Vec reallocate when full — moving every object, which invalidates any raw pointers into the pool but not index handles,
another reason to use indices. Reserve capacity for the expected peak at startup, so growth happens rarely or never in steady state.
When not to pool
Pools add code and a second memory-management scheme to reason about. They pay off for one or a few hot types with high churn. For objects created once and kept, for types of varying size, or for modules where profiles show no allocator cost, the general-purpose allocator is simpler and good enough. Arenas are a better fit when many objects of different types share a lifetime — all freed together at the end of a frame or request — as described in using an arena allocator for per-frame data.
Debugging pooled objects
Pools hide objects from the tools that normally find memory bugs: a leak inside a pool is a slot never returned, invisible to allocator statistics, and a use-after-free through a stale handle is caught only if generations are checked. Add a debug build mode that tracks, per slot, where it was allocated, and expose a function that reports the number of live slots — a count that should return to its baseline after each test or each closed document. A pool whose live count climbs over a session is leaking handles, usually in JavaScript code that forgot to despawn.
Expected output
The particle system spawns and despawns 20,000 particles per second from a pool of 50,000 slots; allocation calls for particles drop to zero after
warm-up; JavaScript holds particles as numeric handles; despawning a stale handle returns false instead of removing another particle; and memory stays
flat over a 30-minute run.
Gotchas
- Indices without generations. Stale handles reach new objects. Add generation counters.
- Raw pointers into a growable pool. Growth moves objects. Use indices.
- Exporting pooled objects as classes. Wrapper churn returns. Pass numeric handles.
- Pooling everything. Complexity without benefit. Pool only hot, fixed-size types.
- Unhandled exhaustion of fixed pools. Return errors at the limit.
Performance note
Moving particles from Box allocations to a pool cut the simulation’s per-frame allocation time from 2.1 ms to under 0.1 ms with 20,000 spawns and
despawns per second, and memory after 30 minutes from 41 MB (and rising) to 12 MB (flat).
Frequently Asked Questions
Is slotmap suitable for Wasm?
Yes — it is plain Rust with no platform dependencies, and its keys are versioned handles.
Can JavaScript read pooled objects directly? Through a typed-array view over a packed field array, if the layout is stable.
How large can generation counters get? With 32 bits, a slot would need four billion reuses to wrap; practically safe.
Do pools help with threads? Per-thread pools avoid allocator lock contention in threaded builds.
Should freed slots be cleared?
Dropping the value (as Option::take does) runs destructors; clearing memory beyond that is only needed for sensitive data.
How do I find leaks inside a pool? Expose the live-slot count and assert it returns to baseline after each test or closed document.
Related
- Implementing a free-list allocator in Wasm — the general version.
- Choosing an allocator for Rust Wasm — when the allocator is the issue.
- Using an arena allocator for per-frame data — shared lifetimes.
- Benchmarking allocation-heavy Wasm code — measuring the gain.
← Back to Linear Memory Management & Allocators