Skip to content

Next.js SSR memory leak: finding what the server is holding on to

  • Next.js
  • SSR
  • Memory Leak
  • Node.js
  • Debugging

The shape of this bug is always the same. Memory on the server climbs steadily, never comes back down, and eventually the process dies:

FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory

Or, on a platform that kills it for you, no stack trace at all — just a restart:

Container exited with code 137

Code 137 is SIGKILL: the OOM killer, not your application. Nothing in your logs explains it, because from Node's point of view nothing went wrong. It was executed from the outside.

The dangerous part is that the workaround is so easy. A scheduled restart every few hours makes the symptom disappear, and teams live on that for months. It works right up until traffic doubles and the interval between restarts becomes shorter than the restarts themselves.

First: confirm it is actually a leak

Rising memory is not a leak. Node grows its heap toward the limit on purpose and collects lazily — a server that climbs to 800MB and plateaus is behaving correctly. A leak is memory that survives garbage collection and never plateaus.

Force the distinction before you spend a day on it:

# Expose the GC so you can collect on demand
node --expose-gc --max-old-space-size=512 node_modules/.bin/next start

Then hit the server with a repeatable load, force a collection, and compare:

global.gc()
console.log(process.memoryUsage().heapUsed / 1024 / 1024, 'MB')

Do that three times, with the same load between each. Heap after GC that returns to roughly the same number each round is a healthy server. Heap after GC that is higher every round is a leak, and the slope tells you how long you have.

--max-old-space-size=512 is there to make the failure fast. A leak that takes four days to kill a 4GB container takes twenty minutes to kill a 512MB one, and you cannot debug what you cannot reproduce.

Taking a heap snapshot from a running server

The snapshot is the whole diagnosis. Everything else is narrowing.

// pages/api/debug-heap.ts — REMOVE before this reaches production traffic
import v8 from 'v8'
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  // Guard it, even in staging: a snapshot pauses the process and dumps memory.
  if (req.query.token !== process.env.HEAP_TOKEN) return res.status(404).end()
  const path = v8.writeHeapSnapshot()
  res.json({ path })
}

Take one snapshot after warmup, drive load, then take a second. In Chrome DevTools → Memory → Load, select the second snapshot and switch the dropdown to Comparison against the first. Sort by "Delta". What grew is what leaks.

The retainers panel is the part to read carefully: it shows why an object could not be collected. Follow it to the first thing you recognize as your own code. That is your leak, and it is almost always one of the five below.

Cause 1: module-scope state that accumulates per request

This is the leak that is specific to SSR, and the most common one by a wide margin.

// Broken: lives for the lifetime of the process, grows with every request.
const cache = new Map<string, User>()

export async function getServerSideProps({ params }) {
  const key = String(params.id)
  if (!cache.has(key)) cache.set(key, await fetchUser(key))
  return { props: { user: cache.get(key) } }
}

On your laptop this looks like a clever cache. In production it is an unbounded Map keyed by user id, on a long-lived server, with no eviction. It grows until the container dies.

Module scope in a Next.js server is process scope. It is shared across every request and every user, and it is never cleaned up between them.

The fix is a bounded cache with an eviction policy:

import { LRUCache } from 'lru-cache'

// Bounded: 500 entries, 5 minutes, and it evicts on its own.
const cache = new LRUCache<string, User>({ max: 500, ttl: 1000 * 60 * 5 })

export async function getServerSideProps({ params }) {
  const key = String(params.id)
  let user = cache.get(key)
  if (!user) {
    user = await fetchUser(key)
    cache.set(key, user)
  }
  return { props: { user } }
}

The security half of this bug, which matters more than the memory half: module-scope state is shared across users. Caching anything request-specific there — a session, a decoded token, "the current user" — means one visitor can be served another visitor's data. That is not a leak, it is a data breach with a leak attached. If you find a module-level let currentUser while chasing memory, stop chasing memory and fix that first.

Cause 2: event listeners and subscriptions added per render

getServerSideProps and API routes run per request. Anything they attach to a long-lived object accumulates at the same rate.

// Broken: one more listener on the process, on every single request.
export async function getServerSideProps() {
  process.on('unhandledRejection', logToSentry)
  return { props: {} }
}

Node warns you about this one, and the warning is easy to scroll past:

MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 unhandledRejection listeners added. Use emitter.setMaxListeners() to increase limit

Treat that warning as a bug report. The fix is to register once at module scope — the one thing module scope is genuinely for:

// Fixed: registered once when the module is first evaluated.
process.on('unhandledRejection', logToSentry)

export async function getServerSideProps() {
  return { props: {} }
}

Never "fix" it with setMaxListeners. That silences the detector, not the leak.

Cause 3: a database client created per request

// Broken: a new pool per request. Each holds sockets and buffers.
export async function getServerSideProps() {
  const prisma = new PrismaClient()
  const posts = await prisma.post.findMany()
  return { props: { posts } }
}

Every connection pool holds file descriptors, TLS state and buffers, and none of it is freed while the pool is referenced. This also exhausts your database's connection limit, so it usually presents as two incidents at once — the server leaking and the database refusing connections.

Use one client for the process, guarded against dev hot-reload:

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

// In dev, HMR re-evaluates modules and would create a client per reload.
// The global survives HMR; in production this is a plain module singleton.
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }

export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Cause 4: closures that capture the whole request

This is the subtle one, and the one heap snapshots are genuinely needed for.

// Broken: the timer's closure captures `req`, which retains headers,
// the socket, and the whole parsed body — for 30 seconds, per request.
export default async function handler(req, res) {
  setTimeout(() => {
    analytics.track('slow_path', { path: req.url })
  }, 30_000)
  res.json({ ok: true })
}

The response is sent immediately, so the request looks finished. But the pending timer holds a reference to req, and req holds a socket, and the socket holds buffers. Under a hundred requests a second, thirty seconds of retention is three thousand live request objects.

Capture only the value you need, so the closure holds a string instead of a request:

export default async function handler(req, res) {
  const path = req.url            // copy the primitive out
  setTimeout(() => {
    analytics.track('slow_path', { path })
  }, 30_000)
  res.json({ ok: true })
}

The same shape appears with promises stored in module scope and anything else that outlives the response while referencing it. When a snapshot's retainer chain ends at IncomingMessage, this is what you are looking at.

Cause 5: rendering an unbounded list into the HTML

Not a leak in the strict sense — memory does come back — but it produces identical symptoms and gets misdiagnosed constantly.

// Broken: 50,000 rows serialized into props, then into __NEXT_DATA__.
export async function getServerSideProps() {
  const rows = await db.query('SELECT * FROM events')  // no LIMIT
  return { props: { rows } }
}

Every row is serialized twice: once as React output, once as JSON in __NEXT_DATA__ for hydration. A few concurrent requests for that page will spike the heap past the limit, and the container dies during a traffic burst rather than gradually.

The tell that distinguishes this from a real leak: memory returns to baseline after the burst. A leak never does. If your graph is sawtooth rather than staircase, look for a missing LIMIT before you take a snapshot.

A quick checklist

  1. Confirm it is a leak: --expose-gc, --max-old-space-size=512, compare heapUsed after forced GC across identical rounds.
  2. Two heap snapshots, Comparison view, sort by Delta.
  3. Read the retainers chain down to your own code.
  4. Grep for module-scope new Map(, new Set(, = [], = {} in anything under pages/, app/ or lib/.
  5. Grep for process.on(, .addListener(, new PrismaClient(, new Pool( inside request handlers.
  6. Grep for setTimeout/setInterval in API routes and check what their closures capture.
  7. Check every getServerSideProps query for a missing LIMIT.

When this is worth outside help

A leak you can reproduce locally is a good afternoon's work with a heap snapshot. It is worth bringing someone in when it only appears under production traffic patterns you cannot generate, when the retainer chain ends inside a dependency instead of your code, or when a scheduled restart has been quietly holding the service together long enough that nobody remembers it is there.

That last case is the one that tends to be expensive, because the restart is load-bearing and removing it is what finally surfaces everything it was hiding. I wrote up what stabilizing a high-traffic SSR marketplace involved — the memory leak was the root cause, and the intermittent failures everyone was actually complaining about were downstream of it.

A leak that only shows up under real traffic tends to travel with hydration problems too, for the same reason: both are invisible on a laptop. If you are seeing hydration mismatches in production alongside the memory climb, they may well share a cause.

A Production Health Audit is the version of this you can buy: I profile the server under conditions that match your traffic rather than your laptop, and hand back a written list of what is leaking, what it costs, and the fix for each one.