Next.js 16.2.4 ISR Memory Leak: Diagnosing Off-Heap Buffer Growth

Next.js 16.2.4 ISR memory leak symptoms usually show up as rising external and arrayBuffers usage. Here is the diagnosis path I trust on self-hosted apps.
Next.js 16.2.4 ISR Memory Leak: Diagnosing Off-Heap Buffer Growth
Next.jsISRPerformance
May 5, 20265 min read943 words

Problem

On April 22, 2026, a self-hosting thread in r/nextjs described a pattern I have seen more than once on high-traffic App Router installs: heap usage looks normal enough, but rss, external, and arrayBuffers climb for hours until the container dies.

That is the kind of issue that gets lazily described as “Next.js has a memory leak.” Sometimes it does. Often the real problem is narrower:

  • long-lived ISR render results pinned in memory
  • a custom cache handler holding onto buffers
  • per-process cache behavior that makes multi-instance self-hosting drift into ugly states

If the app has thousands of ISR routes and sustained crawler traffic, you need to stop guessing and separate heap growth from off-heap growth first.

Next.js’s own self-hosting guide is the key document here. It explicitly says generated cache assets are stored in memory by default and shows the supported way to disable default in-memory caching when you are running multiple instances or custom cache infrastructure.

If you are already working through stale-cache behavior, I covered the application side separately in Next.js use cache Returning Stale Data After Deploy.

Why It Happens

The mistake is looking only at heapUsed.

When the process is leaking off-heap memory, JavaScript heap charts can stay deceptively calm while buffers keep accumulating elsewhere. That is why I always sample the full memory profile:

setInterval(() => {
  const m = process.memoryUsage()
  console.log({
    rss: Math.round(m.rss / 1024 / 1024),
    heapUsed: Math.round(m.heapUsed / 1024 / 1024),
    external: Math.round(m.external / 1024 / 1024),
    arrayBuffers: Math.round(m.arrayBuffers / 1024 / 1024),
  })
}, 60_000)

If external and arrayBuffers trend up linearly while heap stays relatively stable, I start with caching and response buffering, not React state.

Three patterns show up repeatedly:

1. Default in-memory cache plus a self-hosted ISR-heavy app

The Next.js docs are clear that generated cache assets live in memory and on disk by default. On a single persistent instance, that is usually fine. On high-traffic self-hosted installs with lots of dynamic ISR pages, it can become expensive fast.

2. A custom cache handler that stores raw buffers too long

I have seen teams add a Redis or filesystem cache handler, then accidentally keep an additional in-process map of serialized responses “for speed.” At that point you have two caches and one of them never really dies.

3. Large RSC payloads under crawler pressure

If your page serializes too much data, every regeneration path becomes heavier than it should be. Crawler traffic makes the slope obvious.

The Fix

I use a short, boring checklist.

1. Prove whether the growth is heap or off-heap

Do not optimise blind. Capture:

  • process.memoryUsage() every minute
  • one heap snapshot early in uptime
  • one after the process has grown materially

If the buffers are the issue, the snapshots help you inspect retainer chains instead of hand-waving about “Node being weird.”

2. Disable default in-memory caching when your topology calls for it

For self-hosted multi-instance or custom-cache setups, start with the pattern Next.js documents:

module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0,
}

That cacheMaxMemorySize: 0 line matters. It turns off the default in-memory cache so you are not stacking that on top of a custom handler.

3. Audit the custom cache handler, not just the app

I want to know:

  • are we storing raw Buffer objects when strings would do?
  • are expired entries actually evicted?
  • are we duplicating render results in local maps before writing them remotely?

A stripped-down handler should look closer to this than to a clever abstraction:

const cache = new Map()

module.exports = class CacheHandler {
  async get(key) {
    return cache.get(key)
  }

  async set(key, data, ctx) {
    cache.set(key, {
      value: data,
      lastModified: Date.now(),
      tags: ctx.tags,
    })
  }

  async revalidateTag(tags) {
    for (const [key, value] of cache.entries()) {
      if (value.tags?.some((tag) => [tags].flat().includes(tag))) {
        cache.delete(key)
      }
    }
  }
}

If your real handler wraps this in extra memoization or compression layers, inspect those first.

4. Make the ISR payload smaller

This is the part teams skip. If the route renders a huge object graph, you are paying that cost every time regeneration happens. I usually strip:

  • oversized CMS blobs the page does not need
  • duplicate related-content payloads
  • serialized debug metadata left in production fetches

5. Re-test under production traffic shape

next dev tells you almost nothing useful here. Run the built app, hit the hottest ISR routes, and watch memory over time.

The Practical Rule

When a self-hosted Next.js 16.2.4 app shows rising external and arrayBuffers, I do not start by rewriting components. I start by assuming the cache path is guilty until proven otherwise.

That means:

  1. measure full memory usage
  2. disable default in-memory cache where appropriate
  3. inspect the custom handler
  4. reduce ISR payload size

That gets you from “it OOMs after a few hours” to an actual retainer chain you can fix.

CTA

If your Next.js app only falls apart under crawler traffic, long-lived uptime, or self-hosted cache pressure, see my services. I work on production debugging, App Router performance, and the cache-layer fixes most teams do not want to untangle themselves.

Back to blogStart a project