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.

A pool of fixed-size slots in linear memory The pool holds a contiguous array of equal-size slots. Live objects occupy some slots; free slots are linked through a free list whose head points to the next slot to reuse. Allocation pops the head; freeing pushes the slot back. pool: 8 slots × 32 bytes, free-list head → slot 2 live live free → 5 live live free → 7 live free (end) 0 slot 2 slot 5 256 B

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.

One exported object per entity versus pooled handles Exporting each entity as a wasm-bindgen class creates a JavaScript wrapper and a heap allocation per entity and needs a free call per object. Pooling entities in Wasm and passing numeric handles creates nothing on the JavaScript heap and reuses slots, with generations catching stale handles. one class per entity JS wrapper per object allocation + free() each GC and leak risk fine for few objects pooled numeric handles plain numbers in JS slot reuse, no churn stale handles detected many short-lived objects

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).

Allocation time per frame for particle churn Milliseconds per frame spent allocating and freeing particles with individual heap allocations and with a fixed-size pool, at twenty thousand spawns and despawns per second. ms per frame Box per particle 2.1 ms pooled slots 0.1 ms

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.

← Back to Linear Memory Management & Allocators