Skip to content

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

Overview

Memory leaks in JavaScript typically involve detached DOM nodes, unclosed event listeners, timers/intervals that aren't cleared, and objects retained in closures. While JavaScript has garbage collection, resources like event listeners, timers, and large objects in closures can prevent garbage collection, causing memory exhaustion in long-running single-page applications.

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 implement proper cleanup in component lifecycle methods (React useEffect cleanup, Vue beforeUnmount, Angular ngOnDestroy).

Common Vulnerable Patterns

Event Listener Memory Leaks

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 is this 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. Even when removeComponent() removes the component from the components array, the event listener keeps the component alive because the closure captures it. The garbage collector can't reclaim the component's memory. In single-page applications that dynamically create and destroy UI components, this leak compounds rapidly - each component lifecycle leaves behind an orphaned event listener and the component it references. After creating/destroying 1000 components, all 1000 remain in memory along with their DOM references and data. This causes the browser to slow down dramatically (event dispatch becomes O(n) on listener count) and eventually crash with out-of-memory errors.

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
const display = new LiveDataDisplay('https://api.example.com/data');
// User navigates away
display = null;

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

Why is this 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, preventing garbage collection. In single-page apps, components that start timers without cleaning them up accumulate active timers that execute indefinitely. After enough component lifecycle iterations, hundreds of timers execute simultaneously, consuming CPU and memory. Each timer also keeps its associated object and DOM elements alive, compounding the memory leak. Browser timer queues are global and persist until the page unloads.

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 is this vulnerable: When a DOM node is removed from the document but JavaScript still holds a reference to it (in nodeCache), the entire detached DOM subtree remains in memory. The browser can't garbage collect the node because JavaScript references it. Detached nodes are particularly expensive because they include not just the JavaScript wrapper objects but also all the browser's internal C++ DOM structures, layout information, and event listeners. A single detached node can hold megabytes of memory if it has a large subtree. In applications that frequently manipulate the DOM (infinite scrolling, dynamic content), forgetting to remove references to removed nodes causes severe memory leaks. The leak includes all child nodes, event listeners, and associated JavaScript objects in the entire subtree.

Closures Retaining Large Objects

function createHandler() {
    // Large object - 10MB of data
    const largeData = new Array(10_000_000).fill(0);

    // Return small handler function
    return function handler(value) {
        // Only uses value, doesn't reference largeData
        console.log(value);
        // But closure captures entire scope, including largeData!
    };
}

// Store 100 handlers
const handlers = [];
for (let i = 0; i < 100; i++) {
    handlers.push(createHandler());
}

// All 100 handlers keep their 10MB largeData alive
// 1GB of memory leaked! Even though largeData is never used

Why is this vulnerable: JavaScript closures capture the entire scope in which they're created, not just variables they reference. Even though handler doesn't use largeData, the closure includes it because it was in scope when the function was created. This causes every handler to keep its own 10MB largeData alive indefinitely. This pattern is common in event handlers, callbacks, and React component methods that close over props/state - if large objects exist in scope, they're all retained. The leak is invisible because the code doesn't explicitly reference the large data. This is especially problematic in component frameworks where event handlers and callbacks created during render capture props, state, and local variables, keeping them alive even after the component unmounts.

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. This pattern is essential in component-based frameworks (React, Vue, Angular) where components are frequently created and destroyed. Modern frameworks often provide lifecycle hooks (React useEffect cleanup, Vue beforeUnmount) specifically for this cleanup.

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 helps detect if the timer is already stopped. This pattern prevents the timer from running indefinitely and keeping the object alive. 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 have an active timer, causing exponential CPU and memory usage.

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);

// button and its metadata automatically GC'd
// WeakMap doesn't prevent GC of the key (element)

Why this works: WeakMap uses weak references for keys, allowing keys to be garbage collected even when they're in the map. When a DOM node is removed from the document and no other references exist, the garbage collector can reclaim it. The WeakMap entry is automatically removed when the key is collected. This is perfect for associating metadata with DOM nodes, objects, or other keys where you don't want the map itself to prevent garbage collection. Unlike regular Map or object properties, WeakMap won't cause memory leaks from detached nodes. The same principle applies to WeakSet for storing collections of objects that shouldn't prevent GC. This pattern is essential for frameworks and libraries that need to associate data with user objects without creating memory leaks.

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. This provides a standardized place to clean up timers, event listeners, subscriptions, and other resources. The cleanup function is called automatically by React's lifecycle management, preventing developers from forgetting cleanup. Returning a cleanup function from every effect that creates resources ensures no leaks occur during component unmounting, which happens frequently in React applications (navigation, conditional rendering, state changes). This pattern eliminates the most common source of memory leaks in React applications.

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 largeData = new Array(10_000_000).fill(0);

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

    handler.cleanup = function() {
        largeData = null;  // Allow GC
    };

    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. Alternatively, providing an explicit cleanup method that nullifies captured references allows manual memory management. Setting large objects to null breaks the closure's reference, allowing GC while keeping the handler function alive. This pattern is important for event handlers and callbacks that might persist for a long time - avoid capturing large props, state, or computed values in closures; instead, read them from a stable reference (refs in React) or refetch when needed.

Proper AbortController Usage

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;
        }
    }
}

// React component using AbortController
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 provides a standard way to cancel in-flight fetch requests when they're no longer needed. When a component unmounts or the query changes, the previous fetch is aborted, preventing callbacks from executing after the component is gone. This prevents memory leaks from pending requests that would otherwise keep components and their closures alive. Without aborting, rapid user input (typing in search) would create dozens of pending requests, each holding references to the component's state and props. Using AbortController in React's useEffect cleanup ensures all requests are cancelled when the component unmounts or dependencies change, preventing race conditions and memory leaks.

Security Checklist

  • Remove event listeners in component cleanup (unmount, beforeDestroy, ngOnDestroy)
  • Clear all timers and intervals - clearInterval, clearTimeout in cleanup functions
  • Abort pending fetch requests - use AbortController in useEffect cleanup
  • Use WeakMap/WeakSet for DOM node metadata that shouldn't prevent GC
  • Remove DOM node references - clear caches/arrays when nodes are removed
  • Implement cleanup in useEffect - return cleanup function for all resources
  • Avoid large closures - don't capture large objects in event handlers/callbacks
  • Unsubscribe from observables - RxJS, Vue reactivity, custom event emitters
  • Use Chrome DevTools - Memory profiler, heap snapshots to find leaks
  • Monitor memory usage - performance.memory API (Chrome only)
  • Test component lifecycle - mount/unmount repeatedly to detect leaks
  • Use weak event listeners - libraries like weak-event-listener for automatic cleanup
  • Clear global state - remove items from global caches, stores when no longer needed
  • Profile single-page apps - memory grows over time as user navigates

Additional Resources