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
Bufferobjects 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:
- measure full memory usage
- disable default in-memory cache where appropriate
- inspect the custom handler
- 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.
