Regex Tester JSON Formatter Base64 Tool SQL Parser DOM Analyzer Blog
How to Detect Memory Leaks in JavaScript

How to Detect Memory Leaks in JavaScript

By Jumma Dev • 24 May 2026 • Updated 1 week ago

This topic is valuable, but the original contains several misleading claims. Removing a DOM element with a listener does not automatically cause a leak if the element becomes unreachable, Lighthouse is not a dedicated memory-leak detector, and rising memory alone does not prove a leak.

The revised article should focus on repeatable detection, retention paths, heap-snapshot comparison, and correct cleanup patterns.

How to Detect Memory Leaks in JavaScript

JavaScript manages memory automatically, but automatic garbage collection does not make applications immune to memory leaks.

A dashboard, editor, chat application, or single-page application may work correctly at first and gradually become slower after hours of use. Memory consumption may increase, interactions may lag, and the browser tab may eventually crash.

The difficult part is distinguishing a genuine leak from normal temporary memory growth.

This guide explains:

  • What a JavaScript memory leak actually is
  • How garbage collection affects leak detection
  • Which coding patterns commonly retain memory
  • How to use Chrome DevTools correctly
  • How to identify why an object remains reachable
  • How to prevent leaks in framework and non-framework applications

What Is a JavaScript Memory Leak?

A memory leak occurs when an application unintentionally retains memory that it no longer needs.

JavaScript engines use garbage collection to reclaim objects that can no longer be reached by active code. If an unwanted reference still connects an object to a live root, the object remains reachable and cannot be collected.

A simplified example is an unbounded cache:

const userCache = new Map();

function cacheUser(user) {
  userCache.set(user.id, user);
}

If entries are continually added but never removed, the Map retains every user object for the lifetime of the page.

The code may behave correctly, but memory usage continues growing.

JavaScript memory management is based primarily on reachability rather than developers manually freeing individual objects.⁠⁠

Memory Growth Is Not Always a Leak

Increasing memory does not automatically prove that an application is leaking.

JavaScript engines routinely allocate memory for:

  • New objects
  • Strings
  • Arrays
  • Parsed API responses
  • DOM nodes
  • Compiled code
  • Framework data
  • Image and graphics resources

Garbage collection does not necessarily run immediately after an object becomes unnecessary.

A healthy application may show a sawtooth-shaped pattern:

  1. Memory increases as work is performed.
  2. Garbage collection runs.
  3. Memory drops.
  4. The cycle repeats around a stable baseline.

A probable leak looks different:

  1. The same workflow is repeated.
  2. Garbage collection runs.
  3. Memory drops only slightly.
  4. The post-collection baseline continues rising.
  5. Objects from completed workflows remain retained.

The correct question is not:

Did memory increase?

It is:

After the application returns to the same logical state and garbage collection occurs, does retained memory continue increasing?

Symptoms of a Possible Memory Leak

Common symptoms include:

  • Increasing JavaScript heap usage over repeated interactions
  • A growing number of DOM nodes
  • Event-listener counts that never return to baseline
  • Slower interactions after long sessions
  • Longer garbage-collection pauses
  • Increasing CPU usage
  • Lagging animations or scrolling
  • Browser-tab crashes
  • Server processes exceeding memory limits
  • Performance that improves only after reloading the page

These signs justify investigation, but none proves a leak by itself.

How Garbage Collection Works

JavaScript engines begin with a collection of roots.

Examples include:

  • The global object
  • Active function calls
  • Current closures
  • Live DOM references
  • Registered callbacks
  • Timers
  • Event listeners attached to reachable targets
  • Module-level variables

The garbage collector traces references outward from these roots.

An object that remains reachable is retained. An unreachable object becomes eligible for collection.

Consider:

let profile = {
  name: "Alice",
  preferences: {
    theme: "dark"
  }
};

The nested preferences object is reachable through profile.

If the reference is removed:

profile = null;

and no other references exist, both objects can eventually be collected.

Developers do not control exactly when collection occurs. Setting a variable to null removes one reference; it does not force immediate collection and does not help if other references still exist.

Common Causes of JavaScript Memory Leaks

1. Unbounded Caches and Collections

Caches frequently become accidental memory leaks.

const responseCache = new Map();

function storeResponse(key, response) {
  responseCache.set(key, response);
}

If every API response uses a unique key, the cache can grow indefinitely.

Safer strategies

Use one or more of the following:

  • Maximum entry count
  • Time-to-live expiration
  • Least-recently-used eviction
  • Manual invalidation
  • Cache cleanup during logout
  • Pagination instead of caching complete datasets

Example with a simple size limit:

const responseCache = new Map();
const MAX_ENTRIES = 100;

function storeResponse(key, response) {
  responseCache.set(key, response);

  if (responseCache.size > MAX_ENTRIES) {
    const oldestKey = responseCache.keys().next().value;
    responseCache.delete(oldestKey);
  }
}

The exact eviction policy should reflect how the application uses the data.

2. Listeners Attached to Long-Lived Targets

Event listeners do not always cause leaks.

If an element and its listener both become unreachable, the browser can normally collect them together.

The more dangerous pattern is a listener attached to a long-lived target such as:

  • window
  • document
  • body
  • A persistent event bus
  • A shared application object
  • A WebSocket client

The listener’s closure may retain an entire component.

function mountPanel() {
  const panelData = loadLargePanelData();

  function handleResize() {
    updatePanel(panelData);
  }

  window.addEventListener("resize", handleResize);
}

If mountPanel() is called repeatedly and the listener is never removed, every panelData object may remain reachable through window.

Correct cleanup

function mountPanel() {
  const panelData = loadLargePanelData();

  function handleResize() {
    updatePanel(panelData);
  }

  window.addEventListener("resize", handleResize);

  return function unmountPanel() {
    window.removeEventListener("resize", handleResize);
  };
}

Use an AbortSignal

Modern event listeners can also be removed through an AbortController:

function mountPanel() {
  const controller = new AbortController();

  window.addEventListener(
    "resize",
    handleResize,
    { signal: controller.signal }
  );

  document.addEventListener(
    "visibilitychange",
    handleVisibility,
    { signal: controller.signal }
  );

  return function unmountPanel() {
    controller.abort();
  };
}

Aborting the controller removes listeners registered with its signal.

3. Detached DOM Nodes

A detached DOM node is an element no longer connected to the document but still retained by JavaScript.

const savedElements = [];

function removeCard(card) {
  savedElements.push(card);
  card.remove();
}

The card disappears visually, but savedElements continues referencing it.

The retained card may also keep references to:

  • Child elements
  • Event handlers
  • Framework component state
  • Images
  • Text content
  • Associated data

Better approach

If the element is not needed, do not retain it:

function removeCard(card) {
  card.remove();
}

If references are stored for a legitimate reason, remove them when the associated feature is destroyed:

const savedElements = new Set();

function releaseCard(card) {
  savedElements.delete(card);
  card.remove();
}

A detached node is not automatically a leak. It becomes a concern when it remains retained unexpectedly after its interface has been removed.

4. Forgotten Intervals and Repeating Timers

An active interval remains scheduled until it is cleared or its environment is destroyed.

function startPolling() {
  const records = createLargeRecordSet();

  setInterval(() => {
    updateDashboard(records);
  }, 5000);
}

The interval callback retains records.

If this function runs each time a route opens, several polling loops may accumulate.

Correct cleanup

function startPolling() {
  const records = createLargeRecordSet();

  const intervalId = setInterval(() => {
    updateDashboard(records);
  }, 5000);

  return function stopPolling() {
    clearInterval(intervalId);
  };
}

A one-time setTimeout() is generally released after execution, but it can still retain captured data until it runs or is cancelled.

5. Persistent Subscriptions

Subscriptions can keep callbacks and their captured state alive.

Examples include:

  • WebSocket listeners
  • Server-Sent Events
  • RxJS subscriptions
  • Custom event emitters
  • Database listeners
  • Framework stores
  • Real-time messaging clients

Example:

function openChat(roomId) {
  const messages = [];

  socket.on("message", message => {
    messages.push(message);
    renderMessages(messages);
  });
}

If a new handler is registered every time a room opens, old rooms may remain in memory.

The cleanup should remove the same handler:

function openChat(roomId) {
  const messages = [];

  function handleMessage(message) {
    if (message.roomId !== roomId) {
      return;
    }

    messages.push(message);
    renderMessages(messages);
  }

  socket.on("message", handleMessage);

  return function closeChat() {
    socket.off("message", handleMessage);
  };
}

6. Observers That Are Never Disconnected

Browser observer APIs can retain callbacks and observed elements.

Examples include:

  • MutationObserver
  • ResizeObserver
  • IntersectionObserver
  • PerformanceObserver
function observeElement(element) {
  const observer = new ResizeObserver(entries => {
    updateLayout(entries);
  });

  observer.observe(element);
}

Disconnect the observer when the feature is removed:

function observeElement(element) {
  const observer = new ResizeObserver(entries => {
    updateLayout(entries);
  });

  observer.observe(element);

  return function stopObserving() {
    observer.disconnect();
  };
}

7. Closures Retaining Large Objects

Closures are a normal and useful JavaScript feature. They become a problem only when a long-lived function unintentionally retains data that should have been released.

function createHandler() {
  const report = generateLargeReport();

  return function handleClick() {
    console.log(report.title);
  };
}

The returned function requires report, so the report remains reachable as long as the function remains reachable.

Reduce captured data

If only the title is needed:

function createHandler() {
  const report = generateLargeReport();
  const title = report.title;

  return function handleClick() {
    console.log(title);
  };
}

Now the closure does not need to retain the complete report.

Do not try to eliminate closures. Inspect which values they retain and how long the closure lives.

8. Pending Asynchronous Operations

An asynchronous operation may retain callbacks and associated state until it settles.

Consider a route that starts a request:

async function loadProfile() {
  const largeContext = createPageContext();

  const response = await fetch("/api/profile");
  const profile = await response.json();

  renderProfile(profile, largeContext);
}

If the user leaves the page before the request completes, the operation may no longer be useful.

Use AbortController when cancellation is appropriate:

function loadProfile() {
  const controller = new AbortController();

  const promise = fetch("/api/profile", {
    signal: controller.signal
  })
    .then(response => response.json())
    .then(profile => {
      renderProfile(profile);
    })
    .catch(error => {
      if (error.name !== "AbortError") {
        throw error;
      }
    });

  return {
    promise,
    cancel() {
      controller.abort();
    }
  };
}

AbortController can cancel compatible operations such as Fetch requests.⁠⁠

A pending promise is not automatically a leak, but endless or accumulated asynchronous work deserves investigation.

9. Unbounded Application History

Applications sometimes keep every previous state for debugging, undo functionality, or analytics.

const stateHistory = [];

function updateState(nextState) {
  stateHistory.push(nextState);
  render(nextState);
}

If states contain large objects or DOM references, memory can grow rapidly.

Use limits:

const stateHistory = [];
const MAX_HISTORY = 50;

function updateState(nextState) {
  stateHistory.push(nextState);

  if (stateHistory.length > MAX_HISTORY) {
    stateHistory.shift();
  }

  render(nextState);
}

10. Accidental Global Retention

Module-level and global values often live for the page’s entire lifetime.

window.debugData = [];

function track(value) {
  window.debugData.push(value);
}

Temporary debugging collections are easy to forget and can retain application data indefinitely.

Prefer scoped variables, bounded diagnostic buffers, or production telemetry designed to discard old records.

WeakMap and WeakSet: Useful but Not Automatic Fixes

WeakMap and WeakSet hold their object keys weakly. A key can be garbage-collected when no other strong references remain.

Example:

const metadata = new WeakMap();

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

If the element becomes unreachable elsewhere, its WeakMap entry does not keep it alive solely as a key.

This can be useful for associating metadata with:

  • DOM elements
  • Component objects
  • Temporary request objects
  • Library instances

However:

  • Weak collections are not enumerable
  • Only appropriate key types can be used
  • Values may still reference other objects
  • They do not fix unrelated strong references
  • They should not replace deliberate lifecycle management

Use them when weak-key semantics match the data model, not as a general memory optimization.⁠⁠

How to Reproduce a Memory Leak Reliably

Before opening a profiler, define a repeatable scenario.

For example:

  1. Load the dashboard.
  2. Record the initial state.
  3. Open a report.
  4. Close the report.
  5. Repeat 20 times.
  6. Return to the original dashboard.
  7. Wait for pending work to finish.
  8. Compare retained memory and object counts.

A good reproduction returns the application to the same logical state after every cycle.

This makes it easier to determine whether objects from earlier cycles remain alive.

Detecting Leaks with Chrome Performance Monitor

Performance Monitor provides a live overview of browser activity.

To open it:

  1. Open Chrome DevTools.
  2. Open the Command Menu with Ctrl + Shift + P.
  3. Search for Show Performance Monitor.
  4. Select the metrics you want to observe.

Useful metrics include:

  • JavaScript heap size
  • DOM node count
  • Event-listener count
  • Document count
  • CPU usage
  • Layout activity
  • Style recalculations

Chrome documents Performance Monitor as a live view of page performance metrics.⁠⁠

What to look for

Repeat the suspected workflow several times.

A potential problem exists if:

  • DOM nodes increase after every cycle
  • Listener counts never return to baseline
  • Heap usage remains higher after each cleanup cycle
  • Extra documents or frames remain
  • CPU usage continues after the feature closes

Performance Monitor identifies trends. It usually does not reveal the exact retaining reference. Use heap snapshots for that.

Detecting Leaks with Heap Snapshots

A heap snapshot records JavaScript objects and the relationships between them at a particular moment.

Chrome DevTools provides memory-profiling tools for investigating heap growth, detached elements, allocations, and retention paths.⁠⁠

A Reliable Snapshot Workflow

1. Prepare a stable test environment

Reduce unrelated activity:

  • Disable unnecessary extensions
  • Pause background polling when possible
  • Use consistent test data
  • Close unrelated tabs
  • Wait for startup tasks to finish

2. Open the Memory panel

In DevTools:

  1. Select Memory.
  2. Choose Heap snapshot.
  3. Select the correct JavaScript context.

3. Take a baseline snapshot

Capture the application before performing the suspected leaking workflow.

4. Perform the workflow repeatedly

For example:

  • Open and close a modal 20 times
  • Navigate between two routes repeatedly
  • Connect and disconnect a chat session
  • Create and remove a large table
  • Mount and unmount a component

Repeating the action amplifies a small leak and makes it easier to find.

5. Return to the baseline state

If testing a modal, close it. If testing a route, navigate back. If testing a connection, disconnect it.

Allow pending operations to finish.

6. Request garbage collection

When DevTools provides the collection control, request garbage collection before taking the next snapshot.

Forced collection is a diagnostic aid, not proof by itself. Browsers may retain some memory for optimization, and non-JavaScript resources may not appear exactly as expected.

7. Take a second snapshot

Compare it with the baseline.

8. Repeat if necessary

A third snapshot can distinguish persistent growth from one-time initialization.

Understanding Heap Snapshot Views

Heap snapshots provide several useful concepts.

Shallow size

The memory used directly by an object.

Retained size

The approximate memory that could become collectible if the object and objects retained only through it were released.

A small object can have a large retained size if it keeps a large graph alive.

Distance

The number of reference steps between an object and a garbage-collection root.

Retainers

Objects or references preventing the selected value from being collected.

The retaining path is often more useful than the leaked object itself.

Instead of asking:

Why does this array exist?

Ask:

Which root still reaches this array?

The answer may reveal:

  • A global variable
  • An active timer
  • A window listener
  • A framework store
  • A cache entry
  • A closure
  • A WebSocket subscription

Using Snapshot Comparison

In comparison view, look for constructors whose counts or retained sizes continue increasing.

Suspicious entries may include:

  • Component instances
  • Custom model objects
  • Large arrays
  • Event handlers
  • Detached elements
  • Application-specific classes

Do not assume generic objects such as Array, Object, or Function are leaks simply because many exist. Inspect instances, their retaining paths, and whether their growth matches the repeated workflow.

Finding Detached DOM Elements

Heap snapshots can identify DOM elements that have been removed from the document but remain referenced.

A productive workflow is:

  1. Search for detached nodes.
  2. Select a suspicious node.
  3. Inspect its retainers.
  4. Follow the retaining path toward a root.
  5. Identify the application variable, listener, cache, or closure keeping it alive.
  6. Fix the lifecycle cleanup.
  7. Repeat the same snapshot test.

The detached element is often only the symptom. The retention path identifies the actual cause.

Recording Allocations Over Time

Heap snapshots show retained state at specific moments.

Allocation instrumentation records object allocations while the application runs, making it useful when you need to learn which interaction created the suspicious objects.

Use it when:

  • Memory rises during a specific action
  • Many short-lived objects are allocated
  • You need a time-based view
  • Snapshot comparison identifies growth but not the responsible interaction

A typical workflow is:

  1. Start allocation recording.
  2. Perform one controlled action.
  3. Return to the original state.
  4. Stop recording.
  5. Inspect objects allocated during the selected time range.
  6. Examine surviving objects and their retainers.

Memory Leaks in React Applications

React effects often connect components to systems outside React.

Examples include:

  • Timers
  • Event listeners
  • WebSockets
  • Subscriptions
  • Observers
  • Network requests
  • Third-party widgets

An effect should return a cleanup function when it creates a persistent external connection.

import { useEffect } from "react";

function StatusPanel() {
  useEffect(() => {
    const controller = new AbortController();

    async function loadStatus() {
      try {
        const response = await fetch("/api/status", {
          signal: controller.signal
        });

        const data = await response.json();
        updateStatus(data);
      } catch (error) {
        if (error.name !== "AbortError") {
          reportError(error);
        }
      }
    }

    loadStatus();

    return () => {
      controller.abort();
    };
  }, []);

  return null;
}

Timer cleanup:

useEffect(() => {
  const intervalId = setInterval(refreshData, 5000);

  return () => {
    clearInterval(intervalId);
  };
}, []);

React’s effect documentation describes cleanup as the mechanism for disconnecting or undoing external synchronization performed by an effect.⁠⁠

A state update attempted after unmount does not, by itself, prove a memory leak. Investigate whether an external resource, callback, or reference continues to be retained.

Memory Leaks in Vue Applications

Vue components may create external resources during mounting.

Clean them up during unmounting:

import {
  onMounted,
  onUnmounted
} from "vue";

let intervalId;

function handleResize() {
  updateLayout();
}

onMounted(() => {
  intervalId = setInterval(refreshData, 5000);
  window.addEventListener("resize", handleResize);
});

onUnmounted(() => {
  clearInterval(intervalId);
  window.removeEventListener("resize", handleResize);
});

Vue provides lifecycle hooks for setup and cleanup associated with component creation and removal.⁠⁠

Also dispose of third-party libraries that create their own:

  • DOM elements
  • Observers
  • Timers
  • Event listeners
  • Canvas contexts
  • Subscriptions

Detecting Leaks in Node.js

Server-side JavaScript applications can also leak memory.

Common causes include:

  • Unbounded in-memory caches
  • Request objects stored globally
  • EventEmitter listeners
  • Retained database results
  • Unclosed streams
  • Long-lived closures
  • Large queues without backpressure
  • Growing session maps

Useful measurements include:

console.log(process.memoryUsage());

This reports values such as:

  • Resident set size
  • Heap total
  • Heap used
  • External memory
  • Array buffer memory

A repeated production increase does not immediately reveal the cause. Use Node’s inspector and heap snapshots to compare retained objects.

Be careful when capturing production heap snapshots. Creating a snapshot can pause the process and temporarily require substantial additional memory, potentially causing an overloaded service to terminate.⁠⁠

Why Lighthouse Is Not a Memory-Leak Detector

Lighthouse is valuable for auditing areas such as:

  • Loading performance
  • Accessibility
  • Best practices
  • SEO

It may reveal symptoms related to excessive JavaScript or large DOM structures, but it is not designed to prove that an application has a memory leak.

Use these tools instead:

  • Performance Monitor for trends
  • Memory panel for heap analysis
  • Performance panel for runtime activity
  • Node inspector for server-side JavaScript
  • Automated repeated-interaction tests
  • Production memory telemetry

A Practical Memory-Leak Investigation Workflow

Step 1: Define the symptom

Document:

  • Which workflow slows down
  • How long the application must run
  • Which devices are affected
  • Whether reloading fixes the problem
  • Which metrics continue increasing

Step 2: Create a repeatable test

Choose one interaction that returns the application to its original state.

Step 3: Establish a baseline

Record:

  • Heap size
  • DOM-node count
  • Event-listener count
  • Active timers or subscriptions
  • Relevant application object counts

Step 4: Repeat the workflow

Repeat it enough times to amplify small retention.

Step 5: Allow cleanup

Close the feature, cancel requests, and wait for pending work.

Step 6: Trigger diagnostic garbage collection

Use DevTools when available.

Step 7: Confirm retained growth

Determine whether the post-collection baseline continues increasing.

Step 8: Compare heap snapshots

Look for application-specific objects whose counts or retained sizes grow.

Step 9: Follow retaining paths

Find the root responsible for keeping each object reachable.

Step 10: Fix the lifecycle

Remove, disconnect, abort, clear, close, or evict the responsible resource.

Step 11: Repeat the identical test

A fix is not confirmed until the same scenario no longer shows persistent growth.

Memory-Leak Prevention Checklist

Events and observers

  • Remove listeners from long-lived targets
  • Disconnect MutationObserver
  • Disconnect ResizeObserver
  • Disconnect IntersectionObserver
  • Unsubscribe from custom event buses

Timers and asynchronous work

  • Clear intervals
  • Cancel unnecessary timeouts
  • Abort obsolete network requests
  • Close WebSocket and EventSource connections
  • Stop polling when a view is inactive

Collections and caches

  • Limit cache size
  • Expire old entries
  • Remove records after logout
  • Bound history and telemetry buffers
  • Avoid retaining complete API responses unnecessarily

DOM and components

  • Remove stored references to destroyed elements
  • Dispose of third-party widgets
  • Release large canvas and media resources
  • Verify mount and unmount cycles
  • Avoid unnecessary global references

Testing

  • Repeat critical workflows
  • Return to a consistent baseline
  • Compare heap snapshots
  • Inspect retaining paths
  • Monitor long-running sessions
  • Test on memory-constrained devices

Frequently Asked Questions

Does setting a variable to null fix a memory leak?

Only if that variable is the relevant remaining reference. Other listeners, closures, collections, or globals may still retain the object.

Do event listeners on removed elements always leak?

No. If the element and listener become unreachable together, they can normally be collected. Problems occur when another reachable object continues to reference the element or callback.

Does high memory usage always indicate a leak?

No. An application may legitimately use significant memory. A leak is indicated by unwanted retained growth across repeated equivalent workflows.

Can garbage collection be forced from application code?

Portable JavaScript does not provide a standard production API for forcing garbage collection. DevTools and diagnostic runtimes may expose controls for testing.

Should WeakMap replace every Map?

No. Use WeakMap only when keys are objects whose lifetime should not be extended by the collection. It is unsuitable when entries must be enumerated or retained intentionally.

Can TypeScript prevent memory leaks?

No. TypeScript catches many type errors, but it does not automatically manage listeners, timers, subscriptions, caches, or object lifecycles.

Final Thoughts

JavaScript memory leaks are not caused by the absence of garbage collection. They occur when unwanted references make obsolete objects appear reachable.

Effective leak detection requires a repeatable method:

  1. Reproduce the same workflow.
  2. Return the application to its original state.
  3. Observe post-collection memory.
  4. Compare heap snapshots.
  5. Find objects that continue accumulating.
  6. Follow their retaining paths.
  7. Remove the responsible reference or external resource.
  8. Repeat the test to confirm the fix.

The most important lesson is:

Do not search only for objects consuming memory. Find the references that prevent those objects from being collected.