Back Home
2025-01-20 Web Development

Next.js Memory Leaks: Finding and Fixing Them

A practical guide to diagnosing memory leaks in Next.js applications — from App Router gotchas to third-party library traps, with Chrome DevTools workflows.

The Symptoms

Your Next.js app works fine on localhost. But in production, memory creeps up. After a few hours or days, the Node process exhausts its heap and crashes. Or worse: it doesn’t crash, it just gets progressively slower.

Common Causes in Next.js

1. Closures in Server Components

Server Components are rendered per-request. If you capture a large object in a closure that persists beyond the request, you’ve just leaked that data.

//Bad: cache object grows forever
const cache = new Map();
async function getData(id: string) {
  if (cache.has(id)) return cache.get(id);
  const data = await db.query(id);
  cache.set(id, data); // never evicted
  return data;
}

Fix: Use cache() from React or Next.js’s built-in unstable_cache with proper revalidation. Or implement eviction.

2. Event Listeners in useEffect

The classic React leak, but it still happens:

useEffect(() => {
  window.addEventListener('resize', handler);
  // Missing: return () => window.removeEventListener('resize', handler);
}, []);

Fix: Always return a cleanup function from useEffect if you subscribe to anything.

3. Third-Party Libraries That Hold References

Analytics SDKs, WebSocket clients, and charting libraries are frequent offenders. They register global listeners or maintain internal caches that grow without bounds.

Fix: Read the library’s docs for cleanup methods. If none exist, consider a wrapper that manages lifecycle.

4. Router Memory Leaks

The Next.js router can accumulate state if you’re not careful with useRouter() and dynamic routes. This is especially noticeable in apps with heavy client-side navigation.

Fix: Ensure you’re on the latest Next.js version — the team regularly patches memory leaks in the router.

5. Heap Snapshots That Never Clear

If you’re using generateStaticParams with large datasets and the data doesn’t get garbage collected between renders, your build process will consume more memory than necessary.

Fix: Process data in batches. Don’t hold all results in memory at once.

Diagnosing with Chrome DevTools

Step 1: Reproduce Consistently

Memory leaks that only appear in production are usually caused by server-side code. For client leaks:

  1. Open Chrome DevTools → Memory tab.
  2. Take a heap snapshot.
  3. Perform the action you suspect is leaking (navigate between pages, toggle a feature).
  4. Take another snapshot.
  5. Repeat 2-3 times.
  6. Select the last snapshot and change view to “Comparison.”

Step 2: Look for Retained Objects

Sort by “Retained Size.” Look for:

  • Detached DOM nodes — elements removed from the document but still referenced by JS.
  • Growing arrays or Maps — data structures that only grow, never shrink.
  • Closure-scoped variables — objects kept alive by event handlers or callbacks.

Step 3: Trace the Retainers

Click an object in the comparison view and trace its retaining path. This tells you what is keeping it from garbage collection.

Server-Side Diagnosis

For Node.js memory leaks:

  1. Use --heapsnapshot-signal flag (Node 18+): node --heapsnapshot-signal=SIGUSR2 server.js
  2. Send kill -USR2 <pid> to trigger a heap snapshot without restarting.
  3. Load the .heapsnapshot file in Chrome DevTools.

Or use node-memwatch-next to track allocation trends over time.

Prevention Checklist

  • ✅ Always clean up useEffect subscriptions.
  • ✅ Use React’s cache() for server-side data deduplication.
  • ✅ Avoid global mutable state in server components.
  • ✅ Audit third-party libraries for cleanup APIs.
  • ✅ Set memory limits in production (NODE_OPTIONS=--max-old-space-size=4096).
  • ✅ Monitor RSS and heap usage with APM tools.
  • ✅ Load-test before deploying — tools like artillery can surface leaks under volume.

Memory leaks are silent. The first sign is often not an error but a slow, inevitable decline. Catch them early.