Skip to content

CWE-401: Missing Release of Memory after Effective Lifetime - JavaScript

Overview

Memory leaks in JavaScript typically come from detached DOM nodes, event listeners that are never removed, timers and intervals that are never cleared, and objects retained in closures. Garbage collection does not help with any of them: each one is a live reference the collector has to honour, so what it points at accumulates until a long-running single-page application exhausts memory.

Primary Defence: Remove event listeners when components are destroyed, clear timers and intervals when no longer needed, avoid circular references between DOM and JavaScript objects, use WeakMap/WeakSet for cache-like structures that shouldn't prevent GC, and run that cleanup from the component lifecycle hooks (React useEffect cleanup, Vue beforeUnmount, Angular ngOnDestroy).

Common Vulnerable Patterns

Event Listener Memory Leaks

// VULNERABLE - the listener registered in addComponent is never removed, so
// removeComponent drops the array reference and the closure keeps the
// component alive
class ComponentManager {
    constructor() {
        this.components = [];
    }

    addComponent(component) {
        this.components.push(component);

        // Add event listener but never remove
        document.addEventListener('click', (event) => {
            component.handleClick(event);
        });
    }

    removeComponent(component) {
        const index = this.components.indexOf(component);
        this.components.splice(index, 1);
        // Event listener still registered - component can't be GC'd!
    }
}

// Single-page app that creates/destroys components
for (let i = 0; i < 1000; i++) {
    const component = new Component();
    manager.addComponent(component);
    manager.removeComponent(component);
    // Component leaked - event listener holds reference
}

Why this is vulnerable: When addComponent() registers an event listener with a closure capturing component, it creates a strong reference from the global document object to the component. removeComponent() drops the array entry, but the listener is still registered, so the closure still holds the component and the garbage collector cannot reclaim it. Each component lifecycle leaves behind an orphaned listener and the component it references: after creating and destroying 1000 components, all 1000 remain in memory along with their DOM references and data. Event dispatch becomes O(n) on listener count, so every click gets slower as the count grows, and the tab eventually crashes with an out-of-memory error.

Uncleaned Timers and Intervals

class LiveDataDisplay {
    constructor(apiUrl) {
        this.apiUrl = apiUrl;
        this.data = null;

        // Start polling
        this.intervalId = setInterval(() => {
            this.fetchData();
        }, 5000);
    }

    async fetchData() {
        const response = await fetch(this.apiUrl);
        this.data = await response.json();
        this.render();
    }

    render() {
        // Update DOM
    }

    // No cleanup method - interval never cleared!
}

// Component created and destroyed
let display = new LiveDataDisplay('https://api.example.com/data');
// User navigates away
display = null;   // `let`, not `const` - reassigning a const throws
                  // "TypeError: Assignment to constant variable."

// Interval still running! Fetches data every 5 seconds forever
// display object can't be GC'd because interval callback references it

Why this is vulnerable: Timers created with setInterval or setTimeout remain active until explicitly cleared with clearInterval/clearTimeout, even if the object that created them is no longer referenced. The timer callback holds a closure over the object (this.fetchData() captures this), creating a reference from the timer queue to the object, so neither the object nor the DOM elements it holds can be collected. Browser timer queues are global and persist until the page unloads, so a component that starts a timer without clearing it leaves that timer running for the life of the page. After enough mount cycles, hundreds of intervals fire concurrently, each still fetching and each still holding its discarded component.

Detached DOM Nodes

// VULNERABLE - Detached DOM Nodes
// Global cache of DOM elements
const nodeCache = {};

function processNode(elementId) {
    const element = document.getElementById(elementId);
    nodeCache[elementId] = element;  // Store in cache

    // Process element
    element.addEventListener('click', handleClick);
}

function removeFromPage(elementId) {
    const element = document.getElementById(elementId);
    element.parentNode.removeChild(element);
    // Element removed from DOM but still in nodeCache!
    // Can't be GC'd - entire subtree kept in memory
}

// After removing 1000 elements
for (let i = 0; i < 1000; i++) {
    processNode(`element-${i}`);
    removeFromPage(`element-${i}`);
}
// nodeCache holds 1000 detached DOM trees - gigabytes of memory

Why this is vulnerable: When a DOM node is removed from the document but JavaScript still holds a reference to it (in nodeCache), the browser cannot collect it, and the entire detached subtree stays in memory with it - every child node, its event listeners, and the JavaScript objects associated with them. Detached nodes are expensive because they carry not just the JavaScript wrapper objects but the browser's internal C++ DOM structures and layout information, so a single retained node with a large subtree can hold megabytes. Code that manipulates the DOM frequently - infinite scrolling, dynamic content - is where this shows up, because a cache entry that outlives the node is created on every iteration.

Closures Retaining Large Objects

// VULNERABLE - the returned handler retains a 40 MB table it never mentions,
// because a sibling closure in the same scope captured it
function createLogger() {
    // 5 million numbers: V8 gives each element of a packed array its own
    // 8-byte slot, so this is 40 MB of heap
    const lookupTable = new Array(5_000_000).fill(0);

    // Setup only. Never returned, never called again - and this is the line
    // that decides the lifetime of lookupTable.
    const countActive = () => lookupTable.filter(Boolean).length;
    const active = countActive();

    // The only thing that escapes. It does not reference lookupTable.
    return function log(message) {
        return `[${active}] ${message}`;
    };
}

const handlers = [];
for (let i = 0; i < 10; i++) {
    handlers.push(createLogger());
}
// Measured on Node 24.3 / V8 13.6: 400 MB still reachable after a forced GC,
// and a WeakRef to every lookupTable is still live. Deleting the two
// countActive lines drops it to 0 MB.

Why this is vulnerable: V8 does not retain a whole scope for every closure, and the "closures capture the entire scope" folklore overstates it. What V8 allocates is a Context object holding exactly the variables that some function in that scope captures; a variable no inner function mentions stays in a stack slot and dies with the call. Measured on Node 24.3, ten handlers over a 40 MB local array retained 0.4 MB after a forced GC when nothing captured the array - it was collected like any other garbage.

The trap is that the Context is shared by every function created in that scope, not per-closure. log captures only active, but it is compiled with the enclosing Context as its context pointer, and that Context also holds lookupTable because countActive captured it. So a helper you wrote for setup, never returned and never called again, decides how long a 40 MB array lives - and the retaining function is the one that never mentions it. The same shape reaches production through eval or a debugger statement anywhere in the scope, both of which force every variable into the Context because the engine cannot know what will be named.

This is why the leak reads as invisible: nothing in log refers to the data, and grepping the returned function finds nothing. The heap snapshot does name it - the retainer path runs through the closure's context, listing lookupTable as a scope variable - so this is one of the few leaks where the profiler is faster than reading the code. In component frameworks the same mechanism applies to props, state and locals captured during render by any handler defined in that render, not only the one that survives.

Secure Patterns

Proper Event Listener Cleanup

class Component {
    constructor(element) {
        this.element = element;
        // Store bound handler for later removal
        this.clickHandler = this.handleClick.bind(this);
    }

    mount() {
        this.element.addEventListener('click', this.clickHandler);
    }

    unmount() {
        // Remove event listener - breaks reference
        this.element.removeEventListener('click', this.clickHandler);
    }

    handleClick(event) {
        console.log('Clicked:', event.target);
    }
}

// Usage
const component = new Component(document.getElementById('button'));
component.mount();

// Later, cleanup
component.unmount();
// component can now be GC'd

Why this works: Storing the bound handler in an instance variable allows us to remove the exact same function later with removeEventListener. Event listeners must be removed with the same function reference that was added - arrow functions and inline bind() calls create new function objects, making removal impossible. By explicitly removing the listener in unmount(), we break the reference from the DOM node to the component, allowing garbage collection. Component frameworks create and destroy components constantly, which is why they provide lifecycle hooks (React useEffect cleanup, Vue beforeUnmount) for exactly this removal.

Cleaning Timers and Intervals

class PollingService {
    constructor(url, interval = 5000) {
        this.url = url;
        this.interval = interval;
        this.timerId = null;
    }

    start() {
        if (this.timerId) return;  // Already running

        this.timerId = setInterval(() => {
            this.poll();
        }, this.interval);

        // Immediate first poll
        this.poll();
    }

    stop() {
        if (this.timerId) {
            clearInterval(this.timerId);
            this.timerId = null;
        }
    }

    async poll() {
        try {
            const response = await fetch(this.url);
            const data = await response.json();
            this.handleData(data);
        } catch (error) {
            console.error('Poll failed:', error);
        }
    }

    handleData(data) {
        // Process data
    }
}

// Usage
const service = new PollingService('https://api.example.com/data');
service.start();

// Later, cleanup
service.stop();
// service can now be GC'd, no active timers

Why this works: Storing the timer ID (setInterval return value) allows explicit cleanup via clearInterval. Calling clearInterval removes the timer from the browser's timer queue and breaks the reference from the timer callback to the object, allowing garbage collection. Setting timerId = null after clearing is what lets the guard in start() tell a running service from a stopped one, so the service can be restarted and a second stop() does nothing. In React, this cleanup is typically done in the useEffect cleanup function. In class components, timers are started in componentDidMount and stopped in componentWillUnmount. Without cleanup, every component instance that ever existed would still have an active timer, so CPU and memory both grow linearly with the number of mounts the page has ever performed rather than with the number of components currently on screen.

Using WeakMap for Caching

// Weak mapping from DOM nodes to metadata
const nodeMetadata = new WeakMap();

function attachMetadata(element, data) {
    nodeMetadata.set(element, data);
}

function getMetadata(element) {
    return nodeMetadata.get(element);
}

// Usage
const button = document.getElementById('submit');
attachMetadata(button, { clicks: 0, lastClick: null });

// Remove button from DOM
button.parentNode.removeChild(button);

// The WeakMap is now the only thing that could have held the node, and it
// does not - so once `button` itself goes out of scope, the node and its
// metadata are collectable. Note the local is still a strong reference until
// then: a WeakMap removes one retainer, it does not make a value unreachable.

Why this works: WeakMap holds its keys weakly, so a key can be collected while it is still in the map, and the entry is removed with it. When a DOM node is removed from the document and no other references exist, the garbage collector can reclaim it; unlike a regular Map or a plain object property, the map itself will not keep a detached node alive. That makes WeakMap the right place for metadata keyed on DOM nodes or on objects you do not own - the association lasts exactly as long as the key does - and WeakSet the same for a collection of objects that membership should not keep alive.

React useEffect Cleanup

import React, { useEffect, useState } from 'react';

function LiveDataComponent({ apiUrl }) {
    const [data, setData] = useState(null);

    useEffect(() => {
        // Setup: start polling
        const intervalId = setInterval(async () => {
            const response = await fetch(apiUrl);
            const json = await response.json();
            setData(json);
        }, 5000);

        // Cleanup: stop polling when component unmounts
        return () => {
            clearInterval(intervalId);
        };
    }, [apiUrl]);  // Re-run if apiUrl changes

    return <div>{JSON.stringify(data)}</div>;
}

function EventListenerComponent() {
    useEffect(() => {
        const handler = (event) => {
            console.log('Window resized:', event);
        };

        // Setup: add event listener
        window.addEventListener('resize', handler);

        // Cleanup: remove event listener
        return () => {
            window.removeEventListener('resize', handler);
        };
    }, []);  // Empty deps - run once on mount

    return <div>Listening to resize events</div>;
}

Why this works: React's useEffect hook accepts a cleanup function (returned from the effect) that runs when the component unmounts or before the effect re-runs. It gives the teardown for a timer, listener or subscription a home directly beside the setup that created it, and React runs it automatically. Return one from every effect that creates a resource: unmounts happen constantly in a React application - navigation, conditional rendering, state changes - and an effect that sets up without tearing down leaks on each one.

Avoiding Closures Over Large Objects

function createHandler(userId) {
    // Instead of closing over largeData:
    // const largeData = fetchLargeData(userId);
    // return () => { process(largeData); };

    // Better: only capture the identifier
    return async () => {
        // Fetch when needed, not at closure creation
        const largeData = await fetchLargeData(userId);
        process(largeData);
        // largeData can be GC'd after handler returns
    };
}

// Or: explicitly nullify large objects
function createHandlerWithCleanup() {
    // `let`, not `const` - the whole point is that this binding is reassignable
    let largeData = new Array(5_000_000).fill(0);

    const handler = function(value) {
        if (largeData) {
            return process(largeData, value);
        }
        return null;
    };

    handler.cleanup = function() {
        largeData = null;  // clears the Context slot both closures share
    };

    return handler;
}

// Usage
const handler = createHandlerWithCleanup();
handlers.push(handler);

// Later, cleanup
handler.cleanup();

Why this works: By fetching data when needed rather than closing over it, we avoid keeping large objects alive indefinitely. The large object is created, used, and becomes eligible for GC within a single execution.

The cleanup variant works for the reason the vulnerable example failed: handler and cleanup share one Context, so largeData = null in cleanup clears the slot handler reads, and nothing else in the scope is holding the array. Measured on Node 24.3, ten of these retained 400 MB with a live WeakRef to every array; after cleanup() every WeakRef was dead and the retained heap was 0.2 MB, with the handlers still callable and returning null. That sharing is what makes the pattern possible at all.

Prefer the first form. An explicit cleanup() is a resource obligation handed back to the caller, and the caller forgetting it is the leak you started with. Where the data must be captured, two things break the cleanup and neither is visible in a diff of cleanup itself, both measured on Node 24.3:

  • The binding is const. cleanup() throws TypeError: Assignment to constant variable. on its first call, so nothing is released and the failure surfaces at teardown rather than at setup.
  • A second reference to the same array was taken and captured. A sibling closure over the same binding is harmless - nulling it clears the one slot both read, and all ten arrays were collected. A sibling over a const snapshot = largeData is a different slot holding the same object, cleanup() returns without error, and all ten arrays stayed reachable.

So the question to ask of any nulling cleanup is not "does it null the variable" but "how many bindings point at this object, and does the cleanup clear all of them".

Proper AbortController Usage

// data-fetcher.js
class DataFetcher {
    constructor() {
        this.abortController = null;
    }

    async fetch(url) {
        // Cancel previous request if still pending
        if (this.abortController) {
            this.abortController.abort();
        }

        // Create new abort controller for this request
        this.abortController = new AbortController();

        try {
            const response = await fetch(url, {
                signal: this.abortController.signal
            });
            const data = await response.json();
            return data;
        } catch (error) {
            if (error.name === 'AbortError') {
                console.log('Request cancelled');
                return null;
            }
            throw error;
        }
    }

    cancel() {
        if (this.abortController) {
            this.abortController.abort();
            this.abortController = null;
        }
    }
}

The same control inside a component, where the cleanup function is what guarantees the abort - it runs on unmount and again on every change to query, so a fetch in flight for a stale query never resolves into setResults:

// SearchComponent.jsx
import { useEffect, useState } from 'react';

function SearchComponent() {
    const [query, setQuery] = useState('');
    const [results, setResults] = useState([]);

    useEffect(() => {
        const abortController = new AbortController();

        async function search() {
            try {
                const response = await fetch(`/api/search?q=${query}`, {
                    signal: abortController.signal
                });
                const data = await response.json();
                setResults(data);
            } catch (error) {
                if (error.name !== 'AbortError') {
                    console.error('Search failed:', error);
                }
            }
        }

        if (query) {
            search();
        }

        // Cleanup: abort fetch on unmount or query change
        return () => abortController.abort();
    }, [query]);

    return (
        <div>
            <input value={query} onChange={e => setQuery(e.target.value)} />
            <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
        </div>
    );
}

Why this works: AbortController gives fetch a standard cancellation signal. Aborting on unmount or on a query change stops the response callback from running after the component is gone, and releases the pending request's hold on the component's state and props. Without it, rapid typing in the search box leaves dozens of requests in flight, each retaining that state and each able to resolve into setResults after the query has moved on.

Detecting Leaks

A single mount and unmount reveals nothing, because one leaked listener costs almost no memory. Repeat the lifecycle and look for a trend:

  1. Open DevTools, Memory, and take a heap snapshot.
  2. Mount and unmount the component (or navigate in and out of the route) about twenty times.
  3. Force collection with the bin icon, then take a second snapshot.
  4. Switch the view to Comparison against snapshot 1 and sort by delta.

Detached DOM nodes are the clearest signal: filter the snapshot for Detached and any node still listed is being held by something. The retainers pane names the holder, which is usually a listener, a timer, or an array that outlived the node.

Subscriptions leak the same way listeners do, and are easier to miss because they look like data rather than registration: an RxJS subscribe(), a Vue watch, or a custom emitter's on() all keep the callback alive until explicitly unsubscribed. If a component subscribes, the teardown that unsubscribes belongs in the same file, next to it.

Additional Resources