MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

JavaScript Memory Management and Leak Detection

Understand JS memory: garbage collection, memory leaks, profiling tools, and prevention.

JavaScriptMemoryPerformanceDebugging

By MinhVo

Introduction

JavaScript's automatic memory management makes it easy to write code without worrying about allocation and deallocation. However, understanding how garbage collection works and recognizing memory leaks is crucial for building performant applications, especially long-running services and complex single-page applications.

This guide covers JavaScript's memory model, common memory leak patterns, and tools for detecting and preventing memory issues.

Memory Profiling

Understanding the JavaScript Memory Lifecycle

Every program that uses memory follows the same lifecycle regardless of the programming language: allocate, use, and release. In JavaScript, the engine handles all three steps automatically, but understanding what happens under the hood helps you write code that cooperates with the garbage collector rather than fighting it.

Step 1: Allocation

When you declare a variable, create an object, or define a function, the JavaScript engine allocates memory to store that value. The allocation happens on one of two memory regions depending on the type of value.

// Primitive values are allocated on the call stack
let count = 42;          // number — stack
let name = 'Alice';      // string — stack (short strings)
let isActive = true;     // boolean — stack
 
// Objects, arrays, and functions are allocated on the heap
let user = { name: 'Alice', age: 30 };   // heap
let numbers = [1, 2, 3, 4, 5];           // heap
let calculate = (a, b) => a + b;         // heap

Internally, V8 uses a concept called hidden classes to optimize object property access on the heap. When you create objects with the same shape, V8 assigns them the same hidden class, which makes property lookups as fast as array indexing.

Step 2: Using Memory

Reading from or writing to allocated memory is called using memory. This includes accessing object properties, iterating over arrays, invoking functions, and passing values as arguments. During this phase, the allocated memory is considered "live" and must not be reclaimed.

Step 3: Release

When a value is no longer reachable from any part of the running program, the garbage collector reclaims that memory. This is where most memory problems originate: values that should have been released remain reachable due to lingering references.

Stack vs Heap: Two Memory Regions

Understanding the distinction between stack and heap memory is fundamental to reasoning about JavaScript performance.

The Call Stack

The call stack is a contiguous block of memory that stores primitive values and function call frames. It operates as a last-in, first-out data structure. When a function is called, a new stack frame is pushed containing the function's local variables and parameters. When the function returns, its frame is popped and all local primitives are immediately freed.

function add(a, b) {
    let sum = a + b;  // sum lives on the stack
    return sum;        // sum is copied to the caller's frame
}
 
let result = add(3, 4);  // result lives on the stack

Stack allocation and deallocation are extremely fast because they involve only pointer arithmetic. The downside is that stack frames have a fixed maximum size, and the stack cannot store dynamically sized data like objects or arrays.

The Heap

The heap is a large, unstructured pool of memory where objects, arrays, closures, and other complex data structures live. Unlike the stack, heap memory has no predetermined lifetime — it persists until the garbage collector determines it is unreachable.

function createUser(name, settings) {
    // name (a short string) may live on the stack
    // but the object lives on the heap
    return { name, settings, createdAt: Date.now() };
}
 
let user = createUser('Alice', { theme: 'dark' });
// The returned object is on the heap
// 'user' on the stack holds a reference (pointer) to it

V8 divides the heap into several regions:

  • New space (young generation): Where newly allocated objects live. Small (~1-8 MB) and collected frequently using a fast scavenge algorithm.
  • Old space (old generation): Where objects that survived two or more scavenges are promoted. Larger and collected less frequently using mark-sweep-compact.
  • Large object space: Objects that exceed the size threshold for the new space are allocated here directly.
  • Code space: Compiled JIT code.
  • Map space: Hidden classes and other internal structures.

Heap Architecture

Garbage Collection Algorithms in Depth

JavaScript engines use automatic garbage collection to reclaim unreachable memory. The three primary algorithms are mark-and-sweep, generational collection, and the newer Orinoco concurrent collector.

Mark-and-Sweep

The mark-and-sweep algorithm is the foundation of garbage collection in all modern JavaScript engines. It works in two phases:

  1. Mark phase: Starting from a set of root objects (the global object, the current call stack, and any active closures), the collector traverses the object graph and marks every reachable object as "alive."
  2. Sweep phase: The collector scans the entire heap and frees any object that was not marked, since those objects are unreachable.
// Reachability example
let outer = { data: 'important' };
 
function createClosure() {
    let local = { data: 'temporary' };
    return function inner() {
        // 'outer' is reachable from the root via the global scope
        // 'local' is reachable through the closure
        return outer.data + local.data;
    };
}
 
let fn = createClosure();
// Both outer and local are reachable
// The mark phase will find them through the root → fn → closure chain
 
fn = null;
// Now 'fn' is unreachable, the closure and 'local' become unreachable
// The sweep phase will reclaim local's memory

Generational Collection

V8 implements generational collection based on the generational hypothesis: most objects die young. By separating the heap into young and old generations, V8 can collect short-lived objects very frequently (every few milliseconds) with minimal overhead.

Scavenging (young generation): The new space is split into two semispaces — the "from" space where objects are allocated, and the "to" space used as a temporary copy target during collection. The scavenger copies all live objects from the "from" space to the "to" space, then swaps them. Dead objects are implicitly reclaimed because they are not copied.

Objects that survive two scavenges are promoted to the old generation. This promotion strategy keeps the young generation small and fast to collect.

Mark-sweep-compact (old generation): The old generation is collected using a mark-sweep algorithm with an optional compaction step. Compaction relocates surviving objects to eliminate fragmentation, which improves allocation performance for future objects.

Orinoco: Concurrent and Parallel Collection

Modern V8 uses the Orinoco garbage collector, which minimizes pause times through three strategies:

  • Parallel collection: Multiple GC threads work concurrently on different portions of the heap.
  • Concurrent marking: The main thread continues executing JavaScript while a background thread marks reachable objects.
  • Incremental marking: Large mark operations are broken into smaller chunks interleaved with JavaScript execution.

These optimizations mean that for most applications, garbage collection pauses are under 10 milliseconds, even for heaps of several hundred megabytes.

Common Memory Leak Patterns

Memory leaks in JavaScript occur when objects that are no longer needed remain reachable, preventing the garbage collector from reclaiming their memory. Here are the most common patterns.

Pattern 1: Forgotten Event Listeners

Event listeners hold a reference to the callback function, which in turn holds references to its closure scope. If you add an event listener and never remove it, the callback and everything it references remain in memory for the lifetime of the target element.

// LEAK: Listener holds a reference to the component instance
class DataGrid {
    constructor(container) {
        this.data = new Array(10000).fill({ value: Math.random() });
        this.handleClick = this.handleClick.bind(this);
        container.addEventListener('click', this.handleClick);
    }
 
    handleClick(event) {
        // handler references 'this', which holds 'this.data'
        console.log(this.data[event.target.cellIndex]);
    }
}
 
// When the container is removed from the DOM, the DataGrid instance
// and its 10,000-element array stay in memory because the click
// listener on the old container still references the instance.
 
// FIX: Provide a destroy method and call it during teardown
class FixedDataGrid {
    constructor(container) {
        this.container = container;
        this.data = new Array(10000).fill({ value: Math.random() });
        this.handleClick = this.handleClick.bind(this);
        container.addEventListener('click', this.handleClick);
    }
 
    handleClick(event) {
        console.log(this.data[event.target.cellIndex]);
    }
 
    destroy() {
        this.container.removeEventListener('click', this.handleClick);
        this.data = null;
    }
}

In React, the useEffect hook provides a natural cleanup boundary:

function SearchResults({ query }) {
    const [results, setResults] = useState([]);
 
    useEffect(() => {
        const controller = new AbortController();
 
        fetch(`/api/search?q=${query}`, { signal: controller.signal })
            .then(res => res.json())
            .then(setResults);
 
        // Cleanup: abort the fetch and prevent stale state updates
        return () => controller.abort();
    }, [query]);
 
    return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

Pattern 2: Closures Retaining Large Objects

Closures capture references to their enclosing scope. If a closure captures a variable that references a large data structure, that data structure cannot be garbage collected even if the closure never uses it.

// LEAK: process() captures largeData even though it never reads it
function createProcessor() {
    const largeData = new Array(1000000).fill('x'); // ~10 MB
    const summary = { count: largeData.length };
 
    return function process() {
        // Only uses 'summary', but largeData is still captured
        console.log(`Processing ${summary.count} items`);
    };
}
 
// FIX: Null out the reference after extracting what you need
function createFixedProcessor() {
    let largeData = new Array(1000000).fill('x');
    const summary = { count: largeData.length };
    largeData = null; // Allow GC to reclaim the array
 
    return function process() {
        console.log(`Processing ${summary.count} items`);
    };
}

Modern JavaScript engines are increasingly smart about optimizing closure scopes. V8 can sometimes detect that a variable is not used by a closure and exclude it from the closure scope. However, this optimization is not guaranteed, especially when eval() or with statements are present.

Pattern 3: Detached DOM Nodes

When you remove an element from the DOM but keep a JavaScript reference to it, the entire subtree rooted at that element remains in memory. If you accumulate detached nodes over time, memory usage grows without bound.

// LEAK: Removed elements accumulate in the Map
class TooltipManager {
    constructor() {
        this.tooltips = new Map(); // DOM references held indefinitely
    }
 
    attach(element, text) {
        const tooltip = document.createElement('div');
        tooltip.className = 'tooltip';
        tooltip.textContent = text;
        document.body.appendChild(tooltip);
        this.tooltips.set(element, tooltip);
    }
 
    detach(element) {
        const tooltip = this.tooltips.get(element);
        if (tooltip) {
            tooltip.remove(); // Removed from DOM...
            // ...but still in the Map! Memory leak!
        }
    }
}
 
// FIX: Use WeakMap so that when the element is GC'd, the tooltip follows
class FixedTooltipManager {
    constructor() {
        this.tooltips = new WeakMap();
    }
 
    attach(element, text) {
        const tooltip = document.createElement('div');
        tooltip.className = 'tooltip';
        tooltip.textContent = text;
        document.body.appendChild(tooltip);
        this.tooltips.set(element, tooltip);
    }
}

Pattern 4: Uncleared Timers

setInterval and setTimeout callbacks keep references to their closure scope as long as the timer is active. A setInterval that never gets cleared will run indefinitely and prevent any objects it references from being garbage collected.

// LEAK: Interval never cleared
function startPolling(url) {
    const cache = new Map();
 
    setInterval(async () => {
        const response = await fetch(url);
        const data = await response.json();
        cache.set(Date.now(), data); // cache grows without bound
    }, 5000);
}
 
// FIX: Return a cleanup function
function startFixedPolling(url, maxEntries = 100) {
    const cache = new Map();
 
    const id = setInterval(async () => {
        const response = await fetch(url);
        const data = await response.json();
        cache.set(Date.now(), data);
 
        // Evict old entries to prevent unbounded growth
        while (cache.size > maxEntries) {
            const oldest = cache.keys().next().value;
            cache.delete(oldest);
        }
    }, 5000);
 
    return () => {
        clearInterval(id);
        cache.clear();
    };
}

Pattern 5: Global Variables and Singletons

Variables declared without let, const, or var become properties of the global object and persist for the entire lifetime of the application. Singletons that accumulate data over time are another common source of leaks.

// LEAK: Accidental global
function processOrder(order) {
    items = order.items; // Missing 'const' — becomes window.items
    // Every call overwrites the global, but if order.items
    // contains circular references, GC cannot collect them
}
 
// LEAK: Singleton cache grows without bound
class UserService {
    constructor() {
        this.cache = new Map(); // Never evicts entries
    }
 
    async getUser(id) {
        if (this.cache.has(id)) return this.cache.get(id);
        const user = await fetchUser(id);
        this.cache.set(id, user);
        return user;
    }
}
 
// FIX: Use an LRU cache or WeakMap
class FixedUserService {
    constructor(maxCacheSize = 100) {
        this.cache = new Map();
        this.maxCacheSize = maxCacheSize;
    }
 
    async getUser(id) {
        if (this.cache.has(id)) {
            // Move to end (most recently used)
            const user = this.cache.get(id);
            this.cache.delete(id);
            this.cache.set(id, user);
            return user;
        }
        const user = await fetchUser(id);
        this.cache.set(id, user);
 
        // Evict oldest entry if over limit
        if (this.cache.size > this.maxCacheSize) {
            const oldest = this.cache.keys().next().value;
            this.cache.delete(oldest);
        }
        return user;
    }
}

Pattern 6: Console Logging of Large Objects

In some browsers, console.log() retains a reference to the logged object so that you can inspect it later in the DevTools console. If you log large objects in a tight loop, the browser may retain all of them.

// In production, this can accumulate retained references
function processRecords(records) {
    for (const record of records) {
        console.log(record); // Browser retains reference to each record
        // ... process record
    }
}
 
// FIX: Remove console.log in production builds
// Or use a conditional logger
const logger = process.env.NODE_ENV === 'development' ? console : { log: () => {} };

Debugging Memory

WeakRef and FinalizationRegistry

ES2021 introduced two APIs that give developers more nuanced control over garbage collection: WeakRef and FinalizationRegistry.

WeakRef: Weak References to Objects

A WeakRef holds a weak reference to an object, meaning the reference does not prevent the object from being garbage collected. You can check whether the target is still alive by calling deref(), which returns the target if it is still reachable or undefined if it has been collected.

// Implementing a cache with WeakRef
class WeakCache {
    #cache = new Map();
 
    set(key, value) {
        // Store a WeakRef to the value, not the value itself
        this.#cache.set(key, new WeakRef(value));
    }
 
    get(key) {
        const ref = this.#cache.get(key);
        if (!ref) return undefined;
 
        const value = ref.deref();
        if (value === undefined) {
            // Object has been garbage collected
            this.#cache.delete(key);
            return undefined;
        }
        return value;
    }
}
 
// Usage
const cache = new WeakCache();
let heavyObject = { data: new Array(1000000).fill(0) };
cache.set('result', heavyObject);
 
console.log(cache.get('result')); // { data: [...] }
 
heavyObject = null; // Remove the strong reference
// At some future point, the GC may collect the object
// After that, cache.get('result') returns undefined

FinalizationRegistry: Cleanup Callbacks

FinalizationRegistry lets you register a callback that runs after an object has been garbage collected. This is useful for releasing external resources (file handles, network connections, GPU buffers) that the garbage collector cannot reclaim automatically.

const registry = new FinalizationRegistry((heldValue) => {
    console.log(`Resource "${heldValue}" was garbage collected`);
    // Perform external cleanup here
    // e.g., close a file handle, release a GPU texture
});
 
function allocateResource() {
    const resource = { handle: openFileHandle() };
    registry.register(resource, resource.handle);
    return resource;
}
 
// When 'resource' is garbage collected, the callback runs
// and receives resource.handle as heldValue

Important caveats:

  • The callback is not guaranteed to run — the process might exit before GC runs.
  • The timing is non-deterministic — never rely on finalization for critical logic.
  • Avoid creating new strong references inside the callback, as that can cause re-entrance issues.
  • Use finalization only for best-effort cleanup of external resources, not for core application logic.

Detecting Memory Leaks with Chrome DevTools

Chrome DevTools provides a comprehensive suite of tools for diagnosing memory problems.

Heap Snapshots

Heap snapshots capture the entire state of the JavaScript heap at a point in time. By comparing two snapshots, you can identify objects that were created but never garbage collected.

Workflow:

  1. Open Chrome DevTools → Memory tab.
  2. Select "Heap snapshot" and click "Take snapshot."
  3. Perform the action you want to analyze (e.g., navigate between pages, open and close a modal).
  4. Take a second snapshot.
  5. In the comparison view, filter by "Objects allocated between snapshots."
  6. Look for object types whose count is growing — these are potential leaks.

Key columns in the comparison view:

  • #Delta: Number of new objects of this type.
  • Size Delta: Total bytes consumed by the new objects.
  • Retained Size: Total memory that would be freed if this object were collected.

Allocation Timeline

The allocation timeline records memory allocations in real time. Blue bars represent allocated memory, and gray bars represent memory that was freed during garbage collection. A rising trend with little gray indicates a leak.

  1. Open DevTools → Memory tab → select "Allocation instrumentation on timeline."
  2. Click "Start" and interact with the page.
  3. Click "Stop" and analyze the timeline for upward trends.

Allocation Sampling

Allocation sampling profiles memory allocations with minimal overhead by periodically sampling which functions are allocating memory. This is ideal for production-like profiling because it does not pause the application.

Detecting Memory Leaks with Node.js

Node.js provides its own set of tools for memory diagnostics.

Using --trace-gc

The --trace-gc flag prints a line for every garbage collection event, including the type of collection, the heap size before and after, and the time spent.

node --trace-gc server.js

Example output and how to interpret it:

[13973:0x110008000]   44 ms: Scavenge 2.4 (3.2) -> 2.0 (4.2) MB, 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
TokenMeaning
13973Process PID
0x110008000V8 isolate address
44 msTime since process start
ScavengeGC type: Scavenge (young gen) or Mark-sweep (old gen)
2.4 (3.2)Heap used (total) before GC in MB
2.0 (4.2)Heap used (total) after GC in MB
0.5 / 0.0 msGC duration
allocation failureReason: memory was exhausted, triggering collection

Signs of a memory leak from GC traces:

  • Frequent Mark-sweep events with small amounts of memory reclaimed.
  • Old space size growing steadily over time.
  • GC time increasing relative to application execution time.
  • Time between consecutive GC events shrinking.

Reducing --max-old-space-size to Surface Leaks

A powerful technique for finding leaks in Node.js is to reduce the heap limit so that the process crashes with an out-of-memory error. The stack trace at the point of failure reveals the leaking code path.

# Reduce heap to 50 MB to force an OOM quickly
node --trace-gc --max-old-space-size=50 server.js

If the process hits OOM, increment the limit by 10% and run again. If the same pattern repeats, you have confirmed a leak. The stack trace from the OOM shows exactly which allocation caused the failure.

Using process.memoryUsage()

// Monitor heap usage programmatically
function reportMemory(label) {
    const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
    console.log(`[${label}]`, {
        heapUsed: `${(heapUsed / 1024 / 1024).toFixed(1)} MB`,
        heapTotal: `${(heapTotal / 1024 / 1024).toFixed(1)} MB`,
        external: `${(external / 1024 / 1024).toFixed(1)} MB`,
        rss: `${(rss / 1024 / 1024).toFixed(1)} MB`,
    });
}
 
reportMemory('startup');

Taking Heap Snapshots in Node.js with --inspect

node --inspect server.js
# Then open chrome://inspect in Chrome
# Click "inspect" on your Node.js process
# Go to the Memory tab — same workflow as browser DevTools

Object Pooling for High-Frequency Allocations

In applications with high-frequency allocations (games, animations, real-time data visualization), creating and discarding thousands of objects per frame generates excessive garbage collection pressure. Object pooling mitigates this by reusing objects instead of creating new ones.

class ObjectPool {
    #factory;
    #reset;
    #pool = [];
 
    constructor(factory, reset, initialSize = 0) {
        this.#factory = factory;
        this.#reset = reset;
        for (let i = 0; i < initialSize; i++) {
            this.#pool.push(factory());
        }
    }
 
    acquire() {
        if (this.#pool.length > 0) {
            return this.#pool.pop();
        }
        return this.#factory();
    }
 
    release(obj) {
        this.#reset(obj);
        this.#pool.push(obj);
    }
 
    get size() {
        return this.#pool.length;
    }
}
 
// Usage: particle system in a game
const particlePool = new ObjectPool(
    () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, color: '#fff' }),
    (p) => { p.x = 0; p.y = 0; p.life = 0; },
    500
);
 
function emitParticle(x, y) {
    const p = particlePool.acquire();
    p.x = x;
    p.y = y;
    p.vx = Math.random() * 2 - 1;
    p.vy = Math.random() * 2 - 1;
    p.life = 1.0;
    return p;
}
 
function recycleParticle(p) {
    particlePool.release(p);
}

TypedArrays for Memory-Efficient Numeric Data

Standard JavaScript arrays store each element as a boxed object on the heap, which is wasteful for large numeric datasets. TypedArrays provide a flat binary buffer with a fixed memory layout, dramatically reducing memory usage and improving cache performance.

// Standard array: each number is a heap-allocated object (~64 bytes each)
const regular = new Array(100000).fill(0); // ~6.4 MB on heap
 
// TypedArray: raw binary buffer (4 bytes per element)
const efficient = new Int32Array(100000); // ~400 KB
 
// Float64Array for floating-point data
const measurements = new Float64Array(1000000); // ~8 MB (vs ~64 MB for Array)
 
// SharedArrayBuffer for multi-threaded access (requires special headers)
const shared = new SharedArrayBuffer(1024 * 1024); // 1 MB shared buffer
const view = new Int32Array(shared);

TypedArrays also support zero-copy transfer to Web Workers via Transferable, which avoids the overhead of structured cloning:

const buffer = new Float64Array(1000000);
 
// Transfer (not copy) to a worker — the main thread loses access
worker.postMessage(buffer.buffer, [buffer.buffer]);

Real-World Scenarios

Long-Running Node.js Services

A Node.js API server that processes millions of requests will accumulate leaked objects over days or weeks. Monitor process.memoryUsage() in production, and set alerts when heapUsed exceeds a threshold. Use --max-old-space-size to set a reasonable limit and ensure the process crashes rather than consuming all system memory.

Single-Page Applications

SPA frameworks like React, Vue, and Angular create and destroy components as users navigate. Each component mount that adds a global event listener, creates a WebSocket subscription, or starts a polling timer must have a corresponding cleanup. Framework lifecycle hooks (useEffect return, onUnmounted, ngOnDestroy) are the natural place for this cleanup.

// Vue 3 Composition API cleanup
import { onMounted, onUnmounted } from 'vue';
 
export function useResizeObserver(callback) {
    let observer;
 
    onMounted(() => {
        observer = new ResizeObserver(callback);
        observer.observe(document.body);
    });
 
    onUnmounted(() => {
        observer.disconnect();
    });
}

Data Visualization with D3.js or Chart.js

Charting libraries that update frequently (real-time dashboards, stock tickers) must carefully manage SVG elements, canvas contexts, and data arrays. When updating a chart, destroy the previous instance before creating a new one, and use object pooling for data points that are created and discarded in rapid succession.

Testing for Memory Leaks

Automated tests can catch memory leaks before they reach production.

import { describe, it, expect, afterEach } from 'vitest';
 
describe('Component memory management', () => {
    afterEach(() => {
        // Force garbage collection (requires --expose-gc flag)
        if (global.gc) global.gc();
    });
 
    it('should not retain references after destroy', () => {
        const before = process.memoryUsage().heapUsed;
 
        for (let i = 0; i < 1000; i++) {
            const component = new MyComponent();
            component.attach(document.body);
            component.destroy();
        }
 
        if (global.gc) global.gc();
 
        const after = process.memoryUsage().heapUsed;
        const growth = after - before;
 
        // Allow 1 MB tolerance for test overhead
        expect(growth).toBeLessThan(1024 * 1024);
    });
 
    it('should clean up event listeners', () => {
        const addSpy = vi.spyOn(document, 'addEventListener');
        const removeSpy = vi.spyOn(document, 'removeEventListener');
 
        const component = new MyComponent();
        component.destroy();
 
        // Every addEventListener should have a matching removeEventListener
        expect(removeSpy.mock.calls.length).toBe(addSpy.mock.calls.length);
 
        addSpy.mockRestore();
        removeSpy.mockRestore();
    });
});

Memory Management Comparison Across Languages

FeatureJavaScriptJavaC++Rust
AllocationAutomaticAutomaticManual (new/malloc)Ownership system
DeallocationGarbage collectionGarbage collectionManual (delete/free)Ownership + borrowing
Leak riskMedium (lingering references)LowHigh (forgot to free)None (compile-time checks)
GC pausesYes (optimized, usually <10ms)Yes (tunable)NoNo
Control over memory layoutLimited (TypedArrays)LimitedFullFull
Best forWeb apps, serversEnterprise appsSystems, gamesSystems, WebAssembly

Best Practices Summary

  1. Clean up event listeners, timers, and subscriptions in component teardown hooks.
  2. Use WeakMap and WeakSet for metadata attached to objects you do not own.
  3. Use WeakRef for caches that should allow garbage collection.
  4. Null out large references when they are no longer needed, especially in closures.
  5. Object pooling for high-frequency allocations in games and animations.
  6. TypedArrays for large numeric datasets.
  7. LRU caches instead of unbounded Map caches.
  8. Remove console.log of large objects in production builds.
  9. Monitor process.memoryUsage() in Node.js services and set alerts.
  10. Automate memory tests that assert heap growth stays within bounds.

Conclusion

JavaScript's automatic memory management simplifies development, but it does not eliminate the responsibility of writing memory-conscious code. The garbage collector can only free objects that are unreachable — it cannot know whether you still intend to use a reference. By understanding the heap structure, the mark-and-sweep algorithm, and the generational collection strategy, you can write code that cooperates with the garbage collector instead of fighting it.

The most impactful practices are cleanup discipline (always remove listeners and clear timers in teardown hooks), using weak references for caches and metadata, and profiling regularly with Chrome DevTools or Node.js --trace-gc. Incorporating automated memory tests into your CI pipeline catches leaks before they affect production users.

Key takeaways:

  1. V8 divides the heap into young (fast scavenge) and old (mark-sweep-compact) generations.
  2. The most common leak sources are event listeners, closures, detached DOM nodes, and unbounded caches.
  3. WeakRef and FinalizationRegistry provide fine-grained control for caches and external resource cleanup.
  4. Chrome DevTools heap snapshot comparison and Node.js --trace-gc are the primary diagnostic tools.
  5. Object pooling and TypedArrays reduce GC pressure for performance-critical code.

Explore further in the MDN Memory Management documentation, the Node.js diagnostics guide, and the Chrome DevTools memory panel.