
The Slab Allocator
When the garbage collector becomes a bottleneck for Interaction to Next Paint, the only remaining move is to start managing your own memory with ArrayBuffers.
You’ve been told that JavaScript’s garbage collector is a magical, invisible hand that frees you from the drudgery of manual memory management. That is a lie. While the GC is excellent at cleaning up after casual scripts, it is a blunt instrument when you are pushing the limits of the browser. If you are building high-performance data visualizations, complex game engines, or real-time editors where Interaction to Next Paint (INP) is a critical metric, the "automatic" nature of JS memory is actually your biggest bottleneck.
Every time you write const point = { x, y } in a high-frequency loop, you are signing a contract with the Garbage Collector. You get the convenience of the object now, but you pay for it later with a "Stop the World" pause that stutters your animations and spikes your INP.
When the GC becomes your enemy, the only move left is to stop giving it things to collect. We do this by reclaiming the heap and managing our own memory using a Slab Allocator.
The Hidden Cost of the {}
In standard JavaScript, objects are scattered across the heap. When the engine's "Scavenger" or "Mark-and-Sweep" cycles trigger, the engine has to traverse these objects to see what's still alive.
Consider a particle system where each particle is an object:
// This looks innocent, but it's a GC nightmare
function updateParticles(particles) {
return particles.map(p => ({
...p,
x: p.x + p.vx,
y: p.y + p.vy
}));
}Every frame, you create thousands of new objects. Even if they are short-lived, the sheer volume forces the GC to work overtime. The result? A janky UI.
The Slab Allocator changes the paradigm. Instead of many tiny objects, we allocate one massive ArrayBuffer (the "Slab") and treat it as our private memory space. We store our data in that buffer as raw bytes. Since the GC doesn't "see" the data inside an ArrayBuffer as individual objects to be tracked, we effectively hide our data from the collector.
Anatomy of a Basic Slab
A Slab Allocator is essentially a pointer and a big chunk of memory. We move the pointer forward as we "allocate" space and reset it when we're done.
Let's start with a "Bump Allocator" — the simplest form of a slab. It’s incredibly fast but has one catch: you can't free individual items. You have to clear the whole thing at once.
class BumpSlab {
constructor(sizeInBytes) {
this.buffer = new ArrayBuffer(sizeInBytes);
this.view = new DataView(this.buffer);
this.offset = 0;
this.size = sizeInBytes;
}
// Allocate a specific number of bytes
alloc(bytes) {
if (this.offset + bytes > this.size) {
throw new Error("Out of memory!");
}
const address = this.offset;
this.offset += bytes;
return address;
}
// Reset the pointer to "free" everything instantly
reset() {
this.offset = 0;
}
}In a high-frequency loop (like a frame update), you’d alloc what you need, use it, and then reset() at the end of the frame. Because reset() just changes an integer (the offset), it costs effectively zero CPU cycles compared to a GC sweep.
Making it Useful: Structs in JavaScript
Working with raw byte offsets is miserable. To make this practical, we need a way to treat segments of our ArrayBuffer like objects (or "structs" in C-speak).
Imagine we are building a physics engine. Each body needs an id (4 bytes), x (8 bytes), y (8 bytes), vx (8 bytes), and vy (8 bytes). That’s 36 bytes total.
const BODY_SIZE = 36;
const OFFSET_ID = 0;
const OFFSET_X = 4;
const OFFSET_Y = 12;
const OFFSET_VX = 20;
const OFFSET_VY = 28;
const slab = new BumpSlab(1024 * 1024); // 1MB
function createBody(id, x, y, vx, vy) {
const ptr = slab.alloc(BODY_SIZE);
slab.view.setUint32(ptr + OFFSET_ID, id, true);
slab.view.setFloat64(ptr + OFFSET_X, x, true);
slab.view.setFloat64(ptr + OFFSET_Y, y, true);
slab.view.setFloat64(ptr + OFFSET_VX, vx, true);
slab.view.setFloat64(ptr + OFFSET_VY, vy, true);
return ptr;
}I used true for the little-endian flag because almost every modern CPU is little-endian, and being explicit here prevents weird bugs if your code ever runs on exotic hardware.
Wait, I can hear you thinking: *"Writing slab.view.setFloat64 everywhere is a regression in developer experience."* You’re right. But we are trading ergonomics for performance. If you want 120fps with 50,000 active entities, this is the price of entry.
The Alignment Gotcha
CPUs are picky about where they read data from. If you try to read an 8-byte float from an address that isn't a multiple of 8, some architectures will slow down significantly, and some (though rare in the JS world) might even crash.
When building a slab allocator, you must align your pointers. If you allocate 4 bytes for an ID and then want to allocate 8 bytes for a Float64, you should skip forward to the next multiple of 8.
alloc(bytes, alignment = 8) {
// Align the current offset
this.offset = (this.offset + (alignment - 1)) & ~(alignment - 1);
if (this.offset + bytes > this.size) {
throw new Error("OOM");
}
const address = this.offset;
this.offset += bytes;
return address;
}That bitwise magic (this.offset + (alignment - 1)) & ~(alignment - 1) is a standard trick to round up to the nearest power of two. Use it. Love it.
Managing Deallocation with Free Lists
The BumpSlab is great for per-frame data, but what if you need to keep objects around for a long time and delete them sporadically? If you just keep incrementing the offset, you'll run out of memory.
We need a Free List.
The idea is simple: instead of just a pointer, we keep track of "slots" we've finished with. When we "free" a block of memory, we add its address to a list. When we "allocate," we check that list first before moving the main pointer.
For simplicity, let’s assume all objects in our slab are the same size (this is common in game ECS systems or fixed-size data caches).
class FixedSlab {
constructor(slotSize, capacity) {
this.slotSize = slotSize;
this.buffer = new ArrayBuffer(slotSize * capacity);
this.view = new DataView(this.buffer);
this.freeList = [];
this.nextFreeSlot = 0;
this.capacity = capacity;
}
alloc() {
// 1. Check if we have a recycled address
if (this.freeList.length > 0) {
return this.freeList.pop();
}
// 2. Otherwise, take from the end
if (this.nextFreeSlot >= this.capacity) {
throw new Error("No slots left");
}
const address = this.nextFreeSlot * this.slotSize;
this.nextFreeSlot++;
return address;
}
free(address) {
// Just add the address back to the pool
this.freeList.push(address);
}
}Now, we have a system where we can create and destroy "objects" without ever triggering the JS Garbage Collector. We are managing our own memory lifecycle.
Why this improves INP
Interaction to Next Paint measures the latency between a user action (click, keypress) and the next time the browser can paint the screen.
If a user clicks a button exactly when the V8 engine decides it needs to perform a "Major GC" sweep to clean up 50MB of short-lived objects, the click event is queued. The main thread is busy walking the heap, marking objects, and moving them around. The browser cannot respond to the click until that work is done.
By using a Slab, the heap stays tiny. The GC has almost nothing to look at. When the user clicks, the main thread is idle and ready to respond instantly.
I’ve seen apps drop their GC time from 15% of total execution to less than 1% by switching high-churn data structures to ArrayBuffer slabs.
Real World Application: The "Worker" Slab
One of the most powerful uses of a slab is sharing memory between the Main Thread and a Web Worker.
Normally, sending data to a Worker involves postMessage, which serializes the data (essentially making a copy). If you're sending a large state tree every frame, you're creating double the pressure on the GC.
By using a SharedArrayBuffer as your slab, both threads can read and write to the same memory. No copying. No allocation. Just raw speed.
// In main.js
const sharedSlab = new SharedArrayBuffer(1024 * 1024);
const worker = new Worker('physics-worker.js');
worker.postMessage({ buffer: sharedSlab });
// In physics-worker.js
let view;
self.onmessage = (e) => {
view = new DataView(e.data.buffer);
// Now we can mutate memory directly
};
function loop() {
// Update physics in the slab...
view.setFloat64(OFFSET_X, newX, true);
requestAnimationFrame(loop);
}Note: SharedArrayBuffer requires specific security headers (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy) because of Spectre/Meltdown mitigations. It’s a hurdle, but the performance gains are non-negotiable for high-end apps.
The Pitfalls: It's Not All Free Wins
Before you go refactoring your entire Redux store into a TypedArray, let's talk about the downsides.
1. The "UAF" (Use After Free) Bug
In standard JS, if you have a reference to an object, it exists. Period. In a Slab Allocator, you deal with addresses (numbers). If you free(address) but some other part of your code still tries to read from that address, it won't crash. It will just read whatever new data has been put in that slot. This leads to incredibly hard-to-track "ghost" bugs.
2. Lack of Strings
Storing strings in a slab is painful. You have to encode them to UTF-8 using TextEncoder, manage their variable length, and store them as byte arrays. If your data is 90% strings, a slab allocator might not be the right tool—or you’ll need a separate "String Table" to handle them.
3. Debugging
console.log(mySlabObject) just prints a number (the pointer). You can’t easily inspect the state of your data in the Chrome DevTools without writing custom "formatter" logic or view helpers that can parse the bytes at that address.
When should you actually use this?
This is a specialized tool. You don’t need it for a form-heavy SaaS app or a blog. You need it when:
1. High Frequency: You are updating state 60+ times a second.
2. Density: You are managing tens of thousands of individual entities.
3. Predictability: You need a rock-solid frame rate and cannot afford a random 20ms GC pause at a critical moment.
I found that the best approach is often "Hybrid Memory Management." Use standard JS objects for your UI logic, your routing, and your high-level state. But for the core "engine"—the part that does the heavy lifting, the math, and the data processing—move that into a Slab.
Wrapping Up
Building a Slab Allocator is about taking responsibility. You’re telling the JavaScript engine, "I'll take it from here."
It requires a different way of thinking. You start thinking about data in terms of bytes and offsets rather than properties and references. You start caring about cache locality and memory alignment. It’s more work, and your code will look more like C than modern Web2.0 JavaScript.
But when you see that INP score hit the floor and your animations stay buttery smooth even under heavy load, you'll realize that the invisible hand of the GC was actually holding you back.
Stop letting the garbage collector dictate your app's responsiveness. Build a slab, manage your bytes, and take control of your main thread.


