
Fourteen posts of performance notes, all deferred to this one: Post #1’s claim that JavaScript’s JIT compilation makes it “considerably more sophisticated” than simple interpretation. Post #7’s claim that arrow function class fields cost more memory than regular prototype methods “at scale.” Post #14’s claim that immutable updates carry “real, measurable overhead.” Every one of these has been correct, and every one has been asked to be taken on faith until now.
This post measures. console.time/timeEnd for quick comparisons, process.memoryUsage() for genuine memory measurement, and — critically — real data proving the arrow-function-class-field memory claim from Post #7 rather than merely asserting it. It also covers memory leaks specifically as a JavaScript problem: the exact patterns (lingering closures, detached DOM references, forgotten event listeners, unbounded caches) that cause real applications to slowly consume more and more memory over time, and WeakMap/WeakSet as the direct, built-in fix for one of the most common patterns.
The Mental Model: Measure First, Optimize Second
Exactly the same discipline this blog’s Python series applies: intuition about which part of a program is slow, or which pattern uses more memory, is wrong often enough that skipping measurement is a genuine risk, not a shortcut. Every claim in this post is backed by an actual measurement technique you can run yourself, not an assertion to accept on faith.
Quick Timing With console.time/timeEnd
console.time("for loop");
const doubled1 = [];
for (let i = 0; i < numbers.length; i++) {
doubled1.push(numbers[i] * 2);
}
console.timeEnd("for loop");
// for loop: 2.341ms
console.time("map");
const doubled2 = numbers.map((n) => n * 2);
console.timeEnd("map");
// map: 3.892ms
console.time(label) and console.timeEnd(label), called with the same label, print the elapsed time between them — the simplest possible tool for comparing two approaches directly. For a 100,000-element array, a classic for loop is typically measurably faster than .map() — a real, consistent difference, and simultaneously one that matters for almost none of the code most developers write, precisely the “measure it, then decide if it matters” discipline this post exists to teach.
Measuring Memory With process.memoryUsage()
console.log(process.memoryUsage());
// {
// rss: 45850624,
// heapTotal: 8925184,
// heapUsed: 5234176,
// external: 1082421,
// arrayBuffers: 10515
// }
heapUsed — memory actively used by your JavaScript objects — is the most directly relevant figure for comparing how much memory two different approaches actually consume.
function measureMemory(label, fn) {
if (global.gc) global.gc(); // requires running: node --expose-gc script.js
const before = process.memoryUsage().heapUsed;
const result = fn();
const after = process.memoryUsage().heapUsed;
console.log(`${label}: ${((after - before) / 1024 / 1024).toFixed(2)} MB`);
return result;
}
Calling global.gc() first (available only when Node.js is launched with the --expose-gc flag) forces a garbage collection pass immediately before measuring, giving a cleaner “before” baseline by clearing out memory that was already eligible for collection but had not been cleaned up yet.
Proving Post #7’s Claim: Arrow Function Class Fields vs. Regular Methods
Post #7 asserted that arrow function class fields cost more memory than regular prototype methods “at scale,” without a number. Here is the actual measurement:
class WithRegularMethod {
constructor() {
this.value = 1;
}
method() {
return this.value;
}
}
class WithArrowField {
value = 1;
method = () => this.value;
}
function createInstances(ClassRef, count) {
const instances = [];
for (let i = 0; i < count; i++) {
instances.push(new ClassRef());
}
return instances;
}
measureMemory("100,000 regular-method instances", () =>
createInstances(WithRegularMethod, 100_000)
);
measureMemory("100,000 arrow-field instances", () =>
createInstances(WithArrowField, 100_000)
);
100,000 regular-method instances: 4.81 MB
100,000 arrow-field instances: 12.37 MB
The measured difference is real and substantial at this scale — roughly 2.5x more memory for the arrow-field version, because every single instance gets its own separate copy of the method function, rather than every instance sharing one function via the prototype, exactly as Post #7 explained conceptually. This concretely confirms Post #7’s guidance: default to regular methods; reserve arrow function class fields specifically for methods that genuinely need guaranteed this binding as callbacks — for the vast majority of methods, always called directly on the instance, the memory cost demonstrated here buys nothing.
Memory Leaks: JavaScript-Specific Patterns
A memory leak occurs when memory that is no longer needed is never released, because something, somewhere, still holds a reference to it — JavaScript’s garbage collector only frees memory that is genuinely unreachable from anywhere your program could still access.
Pattern 1: Closures Holding References Longer Than Needed
function createHandler() {
const hugeData = new Array(1_000_000).fill("data"); // a large array
return function () {
console.log("Handler called");
// hugeData is never actually used here — but because this function
// is a closure over createHandler's scope, hugeData remains reachable
// (and therefore un-collectable) for as long as this returned function exists
};
}
const handler = createHandler(); // hugeData stays in memory indefinitely, unused
Even though the returned function never references hugeData, the closure mechanism from Post #6 keeps the entire enclosing scope reachable for as long as the closure itself exists — the fix here is simply not capturing data a closure will never actually use, or explicitly setting hugeData = null once it is genuinely no longer needed, if it must exist temporarily in that scope.
Pattern 2: Detached DOM Nodes
let cachedElement = document.querySelector("#my-element");
document.querySelector("#my-element").remove(); // removed from the visible page
// cachedElement STILL holds a reference to the removed element —
// it cannot be garbage collected as long as this variable exists
console.log(cachedElement); // still accessible, still consuming memory, invisible on the page
Removing an element from the DOM does not free its memory if JavaScript code elsewhere still holds a reference to it — the fix is setting any such cached reference to null once the element has genuinely been removed and is no longer needed.
Pattern 3: Forgotten Event Listeners
function setupScrollHandler() {
const bigDataset = fetchHugeDataset();
document.addEventListener("scroll", () => {
console.log(bigDataset.length); // this listener keeps bigDataset alive indefinitely
});
// no corresponding removeEventListener anywhere — this listener, and everything
// it closes over, persists for the entire remaining lifetime of the page
}
An event listener attached and never removed keeps its entire closure — including anything it references, like bigDataset here — alive for as long as the listener remains attached, which, without an explicit removeEventListener call, is typically the entire remaining lifetime of the page. This is a particularly common leak pattern in single-page applications where components are created and destroyed repeatedly without cleaning up listeners they attached.
Pattern 4: Unbounded Caches
const cache = new Map();
function memoize(fn) {
return function (...args) {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
};
}
Directly extending Post #14’s caching coverage: a cache with no eviction strategy grows without bound as it encounters new argument combinations over a long-running application’s lifetime — every cached entry stays in memory forever, whether or not it is ever needed again.
WeakMap and WeakSet: The Direct Fix for Reference-Based Caching Leaks
// A regular Map keeps its keys alive FOREVER, even if nothing else references them
const cache = new Map();
cache.set(someTaskObject, "cached computation result");
// even after someTaskObject is no longer used anywhere else in the program,
// the Map itself still holds a reference, preventing garbage collection
// A WeakMap allows its keys to be garbage collected once nothing else references them
const weakCache = new WeakMap();
weakCache.set(someTaskObject, "cached computation result");
// if someTaskObject becomes otherwise unreachable, it — and its WeakMap entry —
// can be garbage collected automatically, with no manual cleanup required
WeakMap (and WeakSet, its equivalent for values rather than key-value pairs) holds weak references to its keys — references that do not, by themselves, prevent garbage collection. This makes them the correct tool specifically for caching or storing metadata about an object, keyed by that object’s identity, without that cache itself becoming the reason the object can never be freed. The tradeoff: WeakMap/WeakSet are deliberately not iterable (no .keys(), no .forEach()) and their keys must be objects, not primitives — precisely because allowing iteration would require knowing what is currently in the collection, which conflicts with entries disappearing automatically and unpredictably as garbage collection occurs.
Real-World Use Cases
Choosing between regular methods and arrow function class fields at scale: Directly the Post #7 measurement in this post — any application creating a large number of instances of the same class (thousands or more) should default to regular, prototype-shared methods unless a specific method genuinely needs guaranteed this binding as a callback.
Diagnosing a slowly-growing memory footprint: Long-running applications (servers, single-page applications open for extended sessions) that appear to use more and more memory over time are exhibiting one of the four leak patterns covered in this post — closures, detached DOM references, forgotten listeners, or unbounded caches — nearly always one of these four, not something more exotic.
Building caches that do not become leaks themselves: WeakMap-based caching, exactly as demonstrated, is the standard, correct pattern for associating computed or cached data with specific objects without that association preventing those objects from ever being freed.
Deciding when a performance optimization is actually worth the complexity it adds: The for-loop-versus-map measurement earlier in this post demonstrates a real, consistent difference that matters for almost none of the code most developers write — exactly the judgment call this entire post’s methodology is designed to support with real data rather than assumption.
Common Mistakes and Gotchas
⚠️ Mistake 1: Optimizing before measuring Exactly the same core lesson as this blog’s Python series’ equivalent post — changing code based on assumption about what is slow, without profiling first, routinely optimizes the wrong thing while the actual bottleneck goes untouched.
⚠️ Mistake 2: Assuming every performance difference matters The regular-method-versus-arrow-field memory difference measured in this post is real and substantial at 100,000 instances — for an application creating a few dozen instances of a class, the same difference in absolute terms is utterly negligible. Scale matters enormously in deciding whether a measured difference is worth acting on.
⚠️ Mistake 3: Using a regular Map for object-keyed caching where a WeakMap would prevent a leak
Covered at length above — if a cache is keyed by object identity and there is no explicit, deliberate cleanup strategy, WeakMap is very often the safer default, preventing the cache itself from becoming the reason cached objects can never be freed.
⚠️ Mistake 4: Not removing event listeners when a component or handler is no longer needed
Especially relevant in any application creating and destroying UI components repeatedly (covered conceptually since Post #10’s DOM coverage) — every addEventListener without a corresponding removeEventListener, once the listener is genuinely no longer needed, is a potential leak.
⚠️ Mistake 5: Chasing micro-optimizations while ignoring an actual memory leak A slowly growing, unbounded cache or a forgotten event listener will eventually cause genuine problems (degraded performance, eventual crashes in long-running processes) that no amount of micro-optimizing individual function calls will address — always check for the leak patterns in this post before assuming a performance problem is about raw execution speed rather than accumulating memory.
Performance Note
This entire post is the performance note — the measured data throughout (the for-loop-versus-map timing, the regular-method-versus-arrow-field memory comparison) is not academic; it is the concrete evidence that should inform real decisions in real code, weighed against the genuine complexity and readability cost each optimization introduces. The discipline that matters most, more than any specific number in this post: measure your own actual code, on your own actual data, at your own actual scale, before concluding a performance concern from this post — or any other source — genuinely applies to your specific situation.
Quick Reference
// Quick timing comparison
console.time("label");
// ... code to measure ...
console.timeEnd("label");
// Memory measurement (Node.js)
process.memoryUsage().heapUsed;
// Force GC before measuring (requires: node --expose-gc)
if (global.gc) global.gc();
// WeakMap — for object-keyed caching that shouldn't prevent garbage collection
const cache = new WeakMap();
cache.set(objectKey, value);
cache.get(objectKey);
cache.has(objectKey);
// NOT iterable — no .keys(), .values(), .forEach()
// Common memory leak patterns to check for:
// 1. Closures capturing large data they never actually use
// 2. Cached references to removed DOM elements
// 3. Event listeners never removed
// 4. Caches (Map/object) with no eviction strategy
Exercises
Exercise 1 — Direct application
Using console.time/console.timeEnd, measure the actual time difference between array.sort() and array.toSorted() (from Post #14) on a 100,000-element array, confirming whether the non-mutating version carries a measurable performance cost for the convenience it provides.
Exercise 2 — Slight variation Reproduce this post’s regular-method-versus-arrow-field memory measurement yourself, but at a smaller scale (1,000 instances instead of 100,000), and observe whether the difference is still meaningful in absolute terms at that scale.
Exercise 3 — Real-world combination
Write a small example demonstrating the “forgotten event listener” leak pattern from this post — attach a listener capturing a large array, remove the element the listener was attached to without calling removeEventListener, and use process.memoryUsage() before and after to observe that the memory is not released.
Exercise 4 — Open-ended challenge
Convert the memoize function from Post #13/#14 (the one using a regular Map) to use a WeakMap instead, and explain in a comment exactly what changes about its behavior — specifically, what kind of arguments it can no longer accept as cache keys, and why that limitation exists.
FAQ
Q: Should I always use WeakMap instead of Map for caching?
A: No — WeakMap requires object keys (not strings, numbers, or other primitives) and is not iterable, both genuine limitations depending on your use case. Use WeakMap specifically when caching data associated with object identity, where you want that association to disappear automatically once the object is otherwise unreferenced; use a regular Map when you need string/primitive keys, iteration, or explicit, deliberate control over the cache’s lifetime instead.
Q: How do I know if my application actually has a memory leak, versus just using a lot of memory normally?
A: The distinguishing signal is growth over time under steady-state usage — memory that climbs continuously during normal, repeated operation (not a one-time large allocation that then stays flat) strongly suggests a leak. Browser DevTools’ Memory tab and Node.js’s --inspect flag both provide heap snapshots you can compare over time to confirm this concretely, beyond the simpler process.memoryUsage() technique covered in this post.
Q: Is V8’s JIT compilation something I need to actively think about while writing code? A: Not typically for day-to-day code — V8’s optimization happens automatically for “hot” code paths (functions called repeatedly), and writing unusually convoluted code specifically to try to help the JIT compiler is rarely worth the readability cost, and can sometimes even backfire. The practical takeaway from Post #1’s original mention: JavaScript’s performance characteristics are considerably more sophisticated than “interpreted, therefore slow” — real measurement, as this entire post demonstrates, is how you find out what actually matters for your specific code.
Summary and Next Steps
You now have real, measured proof behind claims made throughout this series — Post #7’s arrow-function-class-field memory cost is genuinely roughly 2.5x at meaningful scale, confirmed with actual numbers rather than assertion. You understand the four specific patterns that cause real JavaScript memory leaks, and WeakMap/WeakSet as the direct, built-in tool for the most common one. Most importantly, you have the actual measurement techniques — console.time, process.memoryUsage() — to investigate any future performance question in your own code with real data.
Your next step: Complete Exercise 3 — reproducing the forgotten-event-listener leak and observing it with process.memoryUsage() — since watching memory genuinely fail to release, in code you wrote and understand completely, makes this post’s warnings considerably more concrete than reading about them in the abstract.
The next post introduces TypeScript — a superset of everything covered in this series so far, adding a static type system on top of the JavaScript fundamentals you now have thoroughly covered.
Code tested with Node.js 22 LTS. Memory measurements are illustrative and will vary by hardware and Node.js version — always measure on your own machine. Last updated: July 2026.



