Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 024 / 100

Memory leaks in JavaScript: patterns, pitfalls and fixes

Garbage collection doesn't save you from everything.

Expona Admin7 min read

I spent three hours last week debugging why a React dashboard was consuming 2GB of memory after running for just 10 minutes. It wasn't a data processing issue. It wasn't a rendering problem. Memory leaks in JavaScript had silently accumulated while every part of the system looked correct on the surface. Garbage collection doesn't save you from everything, and that's the hard lesson I want to share today.

JavaScript has automatic garbage collection, which is one of its great strengths. But that safety net has a trap: developers often assume "automatic" means "worry-free." It doesn't. Certain patterns create references that never get cleaned up, and the garbage collector can't touch them. In this article, I'll walk you through the four most dangerous memory leak scenarios I've encountered in production, and how to spot them before they become your 3 AM incident.

Forgotten Listeners: Event Listeners Keep Components Alive

Event listeners are the sneakiest culprit. You attach a listener in a component lifecycle or module initialization, and if you forget to remove it, that listener holds a reference to its context forever. In frameworks like React, this is especially dangerous because components unmount regularly, but the listener persists.

Let me show you a real example from a ride-hailing dashboard I built:

typescript
// BAD: Memory leak in React component
export function MapComponent() {
  const [location, setLocation] = React.useState(null);

  React.useEffect(() => {
    const handleLocationChange = (event: any) => {
      setLocation(event.detail);
    };

    // Listener attached
    window.addEventListener('location-update', handleLocationChange);
    // ❌ Missing cleanup — listener never removed

    return () => {
      // This cleanup function never runs or runs but listener is already orphaned
    };
  }, []);

  return <div>{location?.lat}, {location?.lng}</div>;
}

Each time this component mounts, a new listener is added. If the component remounts (which is common during development with React's strict mode, or in real navigation), you accumulate listeners. The old listeners still hold a reference to the component's closure, keeping it in memory even after unmount.

The fix is straightforward but must be consistent:

typescript
// GOOD: Proper cleanup
export function MapComponent() {
  const [location, setLocation] = React.useState(null);

  React.useEffect(() => {
    const handleLocationChange = (event: any) => {
      setLocation(event.detail);
    };

    window.addEventListener('location-update', handleLocationChange);

    // Cleanup function removes the listener
    return () => {
      window.removeEventListener('location-update', handleLocationChange);
    };
  }, []);

  return <div>{location?.lat}, {location?.lng}</div>;
}

This pattern extends beyond browser events. If you're using custom event emitters (common in NestJS backends or in-process Node services), the same principle applies. Every on(), subscribe(), or addEventListener() must have a corresponding off(), unsubscribe(), or removeEventListener().

Timers: Intervals That Never Clear

setInterval is deceptively simple. You call it once and forget it, and it keeps executing until explicitly cleared. But here's the trap: if the code is cleared or replaced (module hot reload, component remount), the interval persists in the background.

I've seen this pattern cause cascading failures in production. A background sync service starts an interval, the module is reloaded during a deployment, a new interval starts, and the old one keeps running. Within an hour, you have hundreds of duplicate intervals all making network requests.

typescript
// BAD: Interval never cleared
export function startBackgroundSync() {
  setInterval(() => {
    fetchUserData().then(updateCache);
  }, 5000);
  // ❌ This interval runs forever, even if startBackgroundSync is called again
}

// Called multiple times across your app lifecycle
startBackgroundSync();

The solution requires storing the interval ID and providing a way to stop it:

typescript
// GOOD: Interval can be cleared
class BackgroundSyncService {
  private intervalId: NodeJS.Timeout | null = null;

  start() {
    // Prevent duplicate intervals
    if (this.intervalId) return;

    this.intervalId = setInterval(() => {
      fetchUserData().then(updateCache);
    }, 5000);
  }

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

  destroy() {
    this.stop();
  }
}

const syncService = new BackgroundSyncService();
syncService.start();

// Later: explicitly stop
syncService.stop();

The same applies to setTimeout chains. If you're building a retry mechanism or polling, always store the timeout ID and provide a way to cancel it.

Growing Caches: Maps Without Eviction Grow Forever

Caching is a performance optimization, but an unbounded cache is a memory bomb. I learned this the hard way in an e-commerce platform where we cached product details in a Map:

typescript
// BAD: Cache grows forever
const productCache = new Map<string, ProductDetails>();

async function getProduct(id: string): Promise<ProductDetails> {
  if (productCache.has(id)) {
    return productCache.get(id)!;
  }

  const product = await fetchProduct(id);
  productCache.set(id, product); // ❌ No eviction policy
  return product;
}

// After processing 100,000 products, cache holds all 100,000 in memory forever

In a system that processes millions of items over time, this cache will bloat to gigabytes. The garbage collector can't touch it because it's still referenced.

There are several strategies to fix this. The simplest is a size-based limit:

typescript
// GOOD: Cache with size limit
class BoundedCache<K, V> {
  private cache = new Map<K, V>();
  private maxSize: number;

  constructor(maxSize: number = 1000) {
    this.maxSize = maxSize;
  }

  get(key: K): V | undefined {
    return this.cache.get(key);
  }

  set(key: K, value: V): void {
    // Remove oldest entry if at capacity
    if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }

  clear(): void {
    this.cache.clear();
  }
}

const productCache = new BoundedCache<string, ProductDetails>(5000);

For more sophisticated caching, use a library like lru-cache (Node.js) or js-lru (browser), which implement LRU (Least Recently Used) eviction. Redis is also a good option for larger caches in backend systems.

Find Them: Heap Snapshots in DevTools — Compare Before and After

The hardest part of memory leak debugging is often just confirming one exists. DevTools heap snapshots are your best tool. Here's the process I follow:

  1. Open DevTools → Memory tab (Chrome) or Inspector → Memory (Firefox)
  2. Take an initial heap snapshot (label it "Before")
  3. Perform the action you suspect leaks (e.g., open and close a modal 50 times)
  4. Trigger garbage collection (click the trash icon)
  5. Take a second heap snapshot (label it "After")
  6. Compare: use the "Comparison" view or switch between snapshots

In the comparison view, you'll see which objects are growing. If you see 50 instances of your Modal component still in memory after closing it 50 times, you have a leak.

For Node.js services, the process is similar using the built-in inspector:

typescript
// Start your service with inspector
// node --inspect app.js

// Then open chrome://inspect to connect the DevTools debugger
// Take heap snapshots the same way

// Or programmatically using heapdump library
import heapdump from 'heapdump';

// Take snapshot after suspicious operation
heapdump.writeSnapshot(`./snapshots/heap-${Date.now()}.heapsnapshot`);

Once you've identified the leaked objects, look at their retainers (the references keeping them alive). This usually points directly to the problem: a forgotten listener, a running timer, or a cache that never clears.

Common Mistakes

  • Assuming automatic cleanup: Just because JavaScript has garbage collection doesn't mean your code is leak-free. Explicit cleanup is often necessary.
  • Mixing callback contexts: Using arrow functions in listeners that capture `this` can create circular references. Be explicit about binding and unbinding.
  • Forgetting to clear timers before tests: In unit tests, if you don't clear all intervals and timeouts, they leak into the next test. Use a setup/teardown function to clean up.
  • Relying on module reloads: Hot module replacement (HMR) in development can mask leaks. Test with full page reloads too.
  • Caching without limits: Even a "temporary" cache can become permanent if your app lives long enough. Always add bounds or TTL.

Key Takeaways

  • Memory leaks in JavaScript happen when references to objects persist after they should be garbage collected, and they fall into four main categories: forgotten event listeners, uncleared timers, unbounded caches, and circular reference chains.
  • Always provide cleanup functions for listeners, timers, and subscriptions. In React, use the dependency array and return a cleanup function from useEffect.
  • Implement size limits or TTL on caches. Use battle-tested libraries like lru-cache rather than implementing your own.
  • Use heap snapshots to confirm leaks before assuming one exists. Take snapshots before and after the suspected leak, trigger garbage collection, and compare.
  • In production, monitor memory usage over time. If memory climbs steadily without returning to baseline, you likely have a leak. Don't wait for a user to report slowness.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles