Memory in a Shared Sandbox
Part 7 of 9: Series index
At the edge your server has 64 GB of RAM. Your user has maybe a couple of GB of addressable memory in their browser, shared between your site and the forty-seven other tabs they have open. Go over budget and there's no alert, no OOM killer waking up pagers. There's a tab that silently dies, taking whatever unsaved state lived inside it.
Memory management at the edge is a survival skill, not an optimization you get to defer.
How you actually leak
In a language with explicit memory management, leaks are places you forgot to free. In JavaScript, the garbage collector is willing to free anything you've stopped referencing. Leaks are references you didn't realize you kept.
The most common shapes:
Forgotten event listeners. You attach a handler, the handler closes over this, this lives in the closure forever, this pins its own data structures forever, those data structures pin the objects they refer to forever. The entire object graph is unreachable from your code's perspective, but the event bus is still holding the callback.
Detached DOM nodes. You remove a node from the document. You also happen to hold a reference to it in an array somewhere. The node is "gone" from the user's perspective; it's very much alive from the heap's perspective, and so are all its children and all the data bound to it.
Closure over too much. You write const big = expensiveLoad(); return (x) => doThing(big.slice(0, x));. You needed big.length, maybe. Now the returned function closes over the entire big, holding it alive for as long as the function is reachable. Extract the primitives you actually need; let the big value go.
Module-level caches without eviction. A const cache = new Map() at the top of a module, filling up over a session and never emptying. This is the singleton-with-no-lifecycle trap from Post 4 in a different costume.
The pattern behind all four: something lives longer than you intended because something else is still pointing at it. The fix, every time, is either to drop the reference or to make the reference itself a weak one.
WeakMap and WeakSet: the unsung heroes
WeakMap and WeakSet hold weak references to their keys. "Weak" here has a precise meaning: if nothing else in your program references the key, the garbage collector is allowed to collect it, and the map entry disappears with it.
This is the primitive you want whenever you need to associate data with an object you don't own:
const metadata = new WeakMap();
function markComponent(component, info) {
metadata.set(component, info);
}
// When `component` is no longer referenced anywhere else,
// the WeakMap entry is collected automatically. No cleanup.
Contrast with a regular Map: a Map holds strong references, so inserting an object into a Map keeps it alive. A WeakMap doesn't, which is what you want for per-element caches, component metadata, DOM-annotated state, and any other "metadata about objects I don't control the lifetime of."
WeakRef and FinalizationRegistry go further still: arbitrary weak references to individual objects, plus callbacks that fire (eventually, with no timing guarantees) when an object is collected. Advanced tools, so use them sparingly, because relying on GC timing is a sharp edge.
Typed arrays for bulk numeric data
Regular arrays of objects carry per-object overhead: hidden classes, property descriptors, boxed numbers. For a hundred thousand particles, that overhead dominates the actual data.
// ~52 bytes per particle: object overhead plus four boxed numbers
const particles = Array.from({ length: 100_000 }, () => ({ x: 0, y: 0, vx: 0, vy: 0 }));
// ~16 bytes per particle: four 32-bit floats, period
const positions = new Float32Array(100_000 * 2);
const velocities = new Float32Array(100_000 * 2);
Roughly a 70% reduction, with better cache locality and friendlier behavior from the JIT. Typed arrays also transfer between workers at zero cost (see Post 6), which compounds their usefulness for anything data-heavy.
Use them for numerical simulation, graphics, audio buffers, machine-learning inputs, cryptographic operations, and anywhere else you have a lot of same-typed primitive data. Don't use them where the ergonomics of objects actually matter.
Virtual scrolling, a.k.a. windowing
A list of 10,000 items doesn't have to be 10,000 DOM nodes. The user only ever sees twenty at a time. Render those twenty, fake the scrollbar, and as the user scrolls, recycle the same twenty nodes to show whatever is now in view.
This is one of the highest-leverage techniques in browser UI. It turns an O(n) DOM cost into an O(1) DOM cost regardless of dataset size. Libraries exist for every framework; the concept is simple enough to implement by hand in a few dozen lines if needed.
The same idea generalizes: any time your UI shows a fraction of an underlying dataset, there's an argument for rendering only that fraction and virtualizing the rest.
Object pooling
When you're creating and discarding many short-lived objects in a tight loop, say a particle system, a physics simulation, or a game, the GC itself becomes the performance bottleneck, because collecting all that garbage pauses the main thread. The answer is to stop making garbage: pre-allocate a pool, reuse instances from it, reset them instead of re-creating them.
class Pool {
constructor(make, reset, size = 32) {
this.make = make; this.reset = reset;
this.free = Array.from({ length: size }, () => make());
}
acquire() { return this.free.pop() ?? this.make(); }
release(obj) { this.reset(obj); this.free.push(obj); }
}
Not every piece of code needs this. For ordinary application logic, the GC is perfectly capable. Reach for pooling when profiling shows GC pauses becoming user-visible.
Mobile is the edge of the edge
Everything in this post is more urgent on phones, and much more urgent on low-end phones in markets your production telemetry may underrepresent. A two-year-old midrange Android has a fraction of the memory and a fraction of the CPU of a current laptop. The browser will terminate your tab much more aggressively under memory pressure. Users are more likely to be on metered connections where "just refresh" is not a free operation.
navigator.deviceMemory exposes an approximate device memory size in GB (0.25, 0.5, 1, 2, 4, 8). navigator.hardwareConcurrency gives a core count. Use them to scale what you do: smaller caches, lower-quality assets, fewer concurrent workers, simpler animations. Graceful degradation is a feature for the users who need your app the most.
The visibilitychange event and the Page Lifecycle API let you detect when your tab is backgrounded or frozen, and release non-essential memory before the browser forces your hand.
Monitoring
You can measure heap size at runtime via performance.memory (Chromium-only, approximate). A monitoring loop that samples every few seconds, alerts when usage crosses a threshold, and triggers a cleanup event is a five-minute implementation that repeatedly saves you from user-reported "the page just died":
setInterval(() => {
const m = performance.memory;
if (m && m.usedJSHeapSize > 50 * 1024 * 1024) {
window.dispatchEvent(new CustomEvent('memory-pressure'));
}
}, 5000);
window.addEventListener('memory-pressure', () => {
imageCache.clear();
searchIndexCache.clear();
});
Pair that with the Chrome DevTools Memory panel for targeted investigation: heap snapshots, allocation timelines, detached-node detection. For a real leak hunt, the pattern is: take a snapshot, perform the action you suspect, take another snapshot, diff. Most leaks show themselves clearly.
The engineering shift
The mental model shift for engineers coming from server environments is this: on the server, memory is a resource you allocate. At the edge, memory is a resource you negotiate for, with a system that will unilaterally revoke your allocation if it decides you're using too much. The browser answers to the user, not to you.
The discipline this imposes (bounded caches, explicit teardown, weak references, typed arrays, virtualization, monitoring) is the discipline you'd learn running a service on a shared cluster under tight resource limits. Same instinct, different context.
On a cluster you'd treat "OOMkilled" as a bug to hunt down and close. At the edge, "the tab died" is the same bug wearing different clothes, and it deserves the same refusal to accept it.
Next: Architecting for the Edge → (coming soon)