26 — Performance: Memory Leaks, Hidden Classes & Benchmarking
V8 Hidden Classes and Inline Caches
javascript
// ── Hidden classes (Maps/Shapes): V8's object layout optimization ──
// V8 assigns objects a "hidden class" based on their property structure.
// Same property order → same hidden class → fast property access (inline caches).
// ✅ Fast: consistent property order (same hidden class for all instances)
function Point(x, y) {
this.x = x; // hidden class transition: {} → {x}
this.y = y; // hidden class transition: {x} → {x, y}
}
const p1 = new Point(1, 2); // hidden class: Point{x, y}
const p2 = new Point(3, 4); // same hidden class: Point{x, y} → fast!
// ❌ Slow: different property order (different hidden class transitions)
function BadPoint1(x, y) { this.x = x; this.y = y; }
function BadPoint2(x, y) { this.y = y; this.x = x; } // different transition order
const a = new BadPoint1(1, 2); // hidden class: {x, y}
const b = new BadPoint2(1, 2); // hidden class: {y, x} — DIFFERENT → can't share inline caches
// ❌ Adding properties after construction (causes hidden class transitions)
const p3 = new Point(1, 2);
p3.z = 3; // hidden class transition: Point{x, y} → Point{x, y, z} (slower for this instance)
// Future Point instances still use Point{x, y} — p3 has a different (slower) hidden class.
// ── Inline caches: V8 remembers the hidden class at property access sites ──
function getX(obj) { return obj.x; }
getX(p1); getX(p1); getX(p1); // V8 caches: "obj is Point{x,y}, x is at offset N"
getX(p2); // same hidden class → cache hit → fast
getX(b); // different hidden class → cache miss → deoptimize → re-cache
// ── Best practices for hidden class stability ──
// 1. Declare all properties in the constructor (in the same order for all instances)
// 2. Don't add properties after construction (use a fixed set of properties)
// 3. Don't delete properties (causes transitions) — use null/undefined instead
// 4. Avoid polymorphic call sites (same function called with different hidden classes)
Closure-Induced Memory Leaks
javascript
// ── Closures keep captured variables alive — can cause leaks ──
// ❌ LEAK: closure captures the entire large array (even though only .length is used)
function createLeak() {
const hugeArray = new Array(1_000_000).fill("*");
return () => hugeArray.length; // closure captures hugeArray → stays alive
}
const leak = createLeak(); // hugeArray (8MB) stays alive as long as `leak` exists
// ✅ FIX: extract the value, let the large object be GC'd
function createSafe() {
const hugeArray = new Array(1_000_000).fill("*");
const length = hugeArray.length; // extract the value (a number)
return () => length; // closure captures `length` (a number), not `hugeArray`
// hugeArray is no longer referenced → GC'd after createSafe returns
}
// ── Detached DOM elements (closure + DOM = leak) ──
function setupLeak() {
const element = document.querySelector("#removed");
const heavyData = { /* large object */ };
element.addEventListener("click", () => {
console.log(heavyData, element); // closure captures heavyData AND element
});
document.body.removeChild(element); // remove from DOM
// The element and heavyData are still alive (closure holds references) → LEAK
}
// ✅ FIX: use AbortController for clean listener removal
function setupClean() {
const controller = new AbortController();
const element = document.querySelector("#target");
element.addEventListener("click", handler, { signal: controller.signal });
return () => controller.abort(); // cleanup: removes listener → element can be GC'd
}
// ── Timer leaks (setInterval keeps running) ──
function timerLeak() {
setInterval(() => {
console.log("running"); // runs forever — keeps the closure alive
}, 1000);
}
// ✅ FIX: store the ID and clear it
function cleanTimer() {
const id = setInterval(() => console.log("running"), 1000);
return () => clearInterval(id); // cleanup function
}
Benchmarking with performance.now
javascript
// ── performance.now: high-resolution monotonic clock (sub-millisecond) ──
function benchmark(fn, iterations = 10000) {
// Warm up (let V8 optimize the function)
for (let i = 0; i < 1000; i++) fn();
const start = performance.now();
for (let i = 0; i < iterations; i++) fn();
const elapsed = performance.now() - start;
return { elapsed, perOp: elapsed / iterations };
}
const { perOp } = benchmark(() => {
// code to measure
[1, 2, 3].map(x => x * 2);
});
console.log(`${perOp.toFixed(4)}ms per operation`);
// ── Compare two implementations ──
function compare(label1, fn1, label2, fn2, iterations = 100000) {
// Warm up both
for (let i = 0; i < 1000; i++) { fn1(); fn2(); }
const t1 = benchmark(fn1, iterations).perOp;
const t2 = benchmark(fn2, iterations).perOp;
console.log(`${label1}: ${t1.toFixed(6)}ms`);
console.log(`${label2}: ${t2.toFixed(6)}ms`);
console.log(`ratio: ${(t1 / t2).toFixed(2)}x`);
}
// ── Example: parameter expansion vs spread ──
compare(
"spread", () => Math.max(...[1, 2, 3, 4, 5]),
"apply", () => Math.max.apply(null, [1, 2, 3, 4, 5]),
);
// ── ⚠️ Benchmark pitfalls ──
// 1. Warm up first (V8 optimizes after repeated calls — don't measure cold code)
// 2. Run enough iterations (too few → noise dominates)
// 3. Don't measure dead code (V8 might eliminate unused results — use the result)
// 4. Beware of GC pauses (run multiple times, take the median)
// 5. Use `performance.now()` (not `Date.now()` — low resolution, not monotonic)
Deep Clone Performance: structuredClone vs JSON
javascript
// ── Deep clone methods (ranked by performance and capability) ──
// Method 1: structuredClone (modern, handles most types — NOT functions)
const clone1 = structuredClone(original);
// Handles: nested objects, arrays, Date, RegExp, Map, Set, ArrayBuffer, typed arrays, circular refs
// Fails: functions, DOM nodes, Symbols, class instances (loses prototype)
// Performance: fast (native C++ implementation)
// Method 2: JSON.parse(JSON.stringify()) (legacy — lossy)
const clone2 = JSON.parse(JSON.stringify(original));
// Handles: plain objects, arrays, strings, numbers, booleans, null
// Loses: undefined, Date → string, NaN → null, Map/Set → {}, functions → omitted, BigInt → throws
// Performance: slower than structuredClone (string serialization overhead)
// Method 3: manual recursive clone (full control, handles custom types)
function deepClone(obj, cache = new WeakMap()) {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
if (obj instanceof Map) return new Map([...obj].map(([k, v]) => [deepClone(k, cache), deepClone(v, cache)]));
if (cache.has(obj)) return cache.get(obj); // circular reference guard
const clone = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
cache.set(obj, clone);
for (const key of Reflect.ownKeys(obj)) {
clone[key] = deepClone(obj[key], cache);
}
return clone;
}
// ── Benchmark: structuredClone vs JSON vs manual ──
const data = { users: Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `User${i}` })) };
// structuredClone(data): ~1ms (fastest for complex types)
// JSON.parse(JSON.stringify(data)): ~3ms (slower, lossy)
// deepClone(data): ~8ms (slowest, but handles everything)
Lazy Evaluation and Memoization
javascript
// ── Lazy evaluation: compute only when needed ──
// ── Lazy property (computed on first access) ──
class Config {
#parsed = null;
get data() {
if (this.#parsed === null) {
this.#parsed = JSON.parse(this.rawJson); // parse on first access
}
return this.#parsed;
}
constructor(rawJson) { this.rawJson = rawJson; }
}
const config = new Config(rawJson);
// config.rawJson is stored, but data isn't parsed until config.data is accessed.
// If data is never accessed, the parse never runs (saves CPU).
// ── Memoization: cache results (same input → cached output) ──
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const expensiveCompute = memoize((n) => {
console.log("computing..."); // only runs on first call with this input
return n * 2;
});
expensiveCompute(42); // logs "computing...", returns 84
expensiveCompute(42); // returns 84 (cached, no log)
// ── Lazy range generator (compute on demand, not upfront) ──
function* range(start, end, step = 1) {
for (let i = start; i < end; i += step) yield i;
}
const lazyRange = range(0, 1_000_000_000); // doesn't create a billion items
lazyRange.next().value; // 0 (computes on demand)
// Only computes values as they're consumed — O(1) memory, infinite sequences possible.
// ── Virtual list (only render visible items) ──
function* visibleItems(items, scrollTop, viewportHeight, itemHeight) {
const start = Math.floor(scrollTop / itemHeight);
const end = Math.min(start + Math.ceil(viewportHeight / itemHeight), items.length);
for (let i = start; i < end; i++) yield { index: i, item: items[i] };
}
💡 Tips & Tricks
javascript
// ── Avoid creating objects in hot paths (hidden class transitions + GC pressure) ──
// ❌ Creates a new object each call (GC pressure)
function getCoords() { return { x: Math.random(), y: Math.random() }; }
// ✅ Reuse a pre-allocated object (avoid allocation in hot paths)
const coords = { x: 0, y: 0 };
function getCoordsFast() { coords.x = Math.random(); coords.y = Math.random(); return coords; }
// ── Use typed arrays for numeric data (contiguous memory, no boxing) ──
const floats = new Float64Array(1000); // 8KB, contiguous, cache-friendly
// vs: const floats = new Array(1000).fill(0); // each element is a boxed number (slower)
// ── Avoid `arguments` (use rest params — V8 optimizes rest better) ──
// ❌ function f() { return Array.from(arguments); }
// ✅ function f(...args) { return args; }
// ── Avoid `delete` (causes hidden class transition → slow) ──
// ❌ delete obj.prop; (transition + deoptimization)
// ✅ obj.prop = undefined; (keep the hidden class, just null the value)
// ── Use `Object.create(null)` for hash maps (no prototype overhead) ──
const map = Object.create(null); // no inherited properties (toString, hasOwnProperty, etc.)
map.key = "value"; // slightly faster than {} (no prototype chain lookup)
// ── Batch DOM reads/writes (avoid layout thrashing) ──
// Read all layout values first, then write all styles (minimize reflows).
⚠️ Edge Cases & Gotchas
javascript
// ── Microbenchmarking can be misleading (V8 optimizes differently in production) ──
// Hot functions in benchmarks get optimized; in production, cold paths might not be.
// Use real-world profiling (Chrome DevTools Performance tab) for accurate bottlenecks.
// ── `delete` on objects causes hidden class transitions (slow) ──
// Avoid delete — set to undefined/null instead.
// ── Closure capture keeps large objects alive (memory leak) ──
// Extract only what you need from large objects before capturing in closures.
// ── `Date.now()` is low-resolution (ms) and not monotonic (can go backwards on clock sync) ──
// Use `performance.now()` for benchmarking (sub-ms resolution, monotonic).
// ── JSON.parse(JSON.stringify()) loses types (Date → string, undefined → omitted, NaN → null) ──
// Use structuredClone for type-preserving deep clones.
// ── Adding properties after construction causes hidden class transitions ──
// Declare all properties in the constructor for consistent hidden classes.
🧠 Quick Quiz
Why is this code slow for large arrays?
javascript
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
const bigArray = new Array(10_000_000);
for (let i = 0; i < bigArray.length; i++) bigArray[i] = Math.random();
sum(bigArray); // slow
Answer
The array is created with new Array(10_000_000) — a sparse array with holes. The loop fills it with values, but V8 may represent this as a dictionary (slow mode) rather than a packed array (fast mode) because:
new Array(n)creates an array withnholes (empty slots, not packed numbers).- Filling it with
Math.random()converts each hole to a double-precision number, but V8 may not transition to a packedPACKED_DOUBLE_ELEMENTShidden class until it's certain all elements are filled. - Each
arr[i]access on a hole-containing array is slower (extra checks).
Fix: Create a packed array from the start:
// ✅ Packed array (fast — V8 uses PACKED_DOUBLE_ELEMENTS)
const bigArray = Array.from({ length: 10_000_000 }, () => Math.random());
// or: const bigArray = new Float64Array(10_000_000); // typed array (fastest for numbers)
The lesson: new Array(n) creates a sparse array with holes. V8 optimizes packed arrays (no holes, consistent element types) much better than sparse arrays. Use Array.from() or typed arrays for large numeric arrays.