Next.js Memory Leak on Self-Hosted Search Routes: A Case Study
In April 2026, I migrated a global directory site serving 100,000+ monthly visitors off Next.js and onto a plain Express and EJS stack. The trigger was months of a production server running out of memory, crashing, and showing a maintenance screen to whichever users happened to hit it during the restart window, on a version of Next.js that had shipped only a few months earlier.
This covers what caused that, what Next.js has and hasn’t fixed about it since, and whether it could have been solved without migrating off the framework.
TL;DR
- The site has route patterns like
/search/:query,/goat/:query,/butter/:query,/colostrum/:query, each combined with arbitrary geography and radius. That’s an effectively unbounded space of unique URLs. - Every one of those dynamic pages was cached with a 10-minute
revalidate, on Next.js 16.2.1, one of the newest releases available at the time. - Memory climbed until it hit a manually imposed PM2 cap, which triggered a restart and a maintenance screen for whoever was mid-request.
- This matches a documented, still-actively-reported Next.js failure pattern going back to 2021, not a one-off bug in this codebase.
- Next.js has changed its caching defaults twice since (Next 15 in 2024, Next 16 in 2025) specifically to reduce this class of problem, but neither change protects a route that explicitly opts back into caching, which this one did.
- It probably could have been mitigated without a full migration, by externalizing the cache to Redis or bounding the query space. Neither is a small change, and both converge on close to the same architecture the migration ended up with anyway.
- This is close to a self-hosting-specific failure mode. Vercel’s Data Cache lives in external infrastructure, not inside the function’s own process memory, so the same code deployed there is largely immune to this particular mechanism by default.
The setup: an effectively unbounded route space
The site is a raw milk farm directory with listings across 60+ countries. Its search surface isn’t a fixed set of pages, it’s generated from combinations of:
- Free-text search queries
- Product category filters (goat milk, raw butter, colostrum, and others, each with its own route)
- Geographic radius searches anchored to arbitrary coordinates
- Social referral variants like
/social/facebook/:query
Multiply those axes together and the number of distinct URLs the app can be asked to render isn’t in the thousands, it’s functionally unbounded. Any caching strategy applied to this route space has to account for that cardinality directly, or it will eventually try to hold an unbounded number of entries in a bounded amount of memory.
What was actually cached, and why that mattered
Every one of those dynamic [query] pages was cached with revalidate: 600 (a 10-minute time-to-live), a reasonable-looking choice on its face: repeat searches for the same query and area would get served fast, and results would still refresh often enough to stay current.
The problem with a TTL-only strategy against an unbounded key space is that a time-to-live bounds how long any single cache entry lives, but it does nothing to bound how many entries can exist at once. On a directory site fielding constant search traffic from 60+ countries, unique queries arrive faster than 10-minute-old entries expire, so the cache grows continuously instead of reaching a steady state. This matches the pattern documented against Next.js’s cache internals since 2021.
The symptom: memory that only went one direction
There’s no memory graph to show for this period, because Linode’s standard server dashboard doesn’t chart memory usage at all, only CPU, disk I/O, and network throughput. That gap in default observability is part of why this took a while to fully characterize: CPU and network both looked fine even as memory climbed steadily toward the ceiling in the background.
The only real signal was PM2’s memory cap. With no limit set, the process would grow until the server ran out of memory outright and crashed. Setting max_memory_restart: 1G in the PM2 config turned that into a managed failure instead of an unmanaged one: the process would climb toward 1GB, PM2 would kill and restart it, and any user whose request landed during that restart window saw a maintenance screen for a few seconds. It was survivable, but it was a symptom being treated, not a cause being fixed.
PM2’s restart log shows just over 270 restarts across the deployment period, though that figure isn’t a clean crash count. It conflates memory-cap-triggered restarts with ordinary restarts from manual deploys during active development, and there’s no reliable way to separate the two after the fact from the log alone. It’s included here as context for the scale of instability, not as a precise crash frequency.
The CPU data from Linode’s dashboard tells an adjacent part of the story, migration month over migration month:
| Month | Avg CPU | Max CPU | Avg Disk I/O |
|---|---|---|---|
| March 2026 (Next.js, pre-migration) | 32.77% | 99.97% | 14.25 blocks/s |
| April 2026 (migration month) | 27.12% | 48.39% | 9.16 blocks/s |
| May 2026 (Express/EJS, post-migration) | 13.88% | 27.58% | 8.96 blocks/s |
Average CPU load roughly halved after the migration, and the March max of 99.97%, a spike to near-full saturation, never recurred in May. This isn’t direct evidence about the memory mechanism specifically, CPU and memory are separate resources, but it shows the overall resource pressure from this route architecture dropped substantially once the framework changed.
A brief history of Next.js’s cache defaults
Next.js has changed this default twice, and the timing matters:
- Through Next.js 14:
fetch()requests and GET Route Handlers were cached by default, implicitly, with no explicit opt-in required. A route that fetched data without specifying cache behavior got cached automatically. - Next.js 15 (October 21, 2024): Vercel flipped this default. Fetch requests, GET Route Handlers, and client-side navigations became uncached by default. The release notes cite exactly this class of problem, unpredictable implicit caching, as the motivation.
- Next.js 16 (October 21, 2025): Cache Components and the
use cachedirective extended opt-in caching to the component and page level, not just individual fetches. Nothing is cached anywhere in an app unless a developer explicitly marks it.
Both changes were direct responses to this failure mode. That makes the version in play here significant: this site was running Next.js 16.2.1, current as of that spring, already on the newest opt-in caching model Next.js has shipped.
That’s also exactly why those defaults didn’t help. The new defaults protect routes that don’t explicitly ask to be cached. This route explicitly did, with revalidate: 600 on every dynamic search page, which is a completely reasonable thing to want for a search feature. Opting into caching on an unbounded key space reproduces the old high-cardinality growth problem regardless of what the framework’s default behavior is, because the default was never the thing that mattered once caching was explicitly requested.
The known failure pattern, 2021 through this spring
This pattern has been reported repeatedly:
- 2021, discussion #26801: an early Kubernetes deployment report describing Next’s IncrementalCache as an in-memory LRU that will grow to its documented 50MB cap “and stay there indefinitely,” with a later comment in the same thread confirming the cache had in fact grown past that cap in practice.
- 2024, issue #68578: a reproducible case showing memory climbing from ~45MB to ~400MB after a load test and never coming back down, even once the server returned to idle. Vercel triaged this as likely a Node.js-level issue rather than a Next.js-specific one; one commenter reported resolving a similar production leak by downgrading Node.js to 20.15.1.
- 2025, issue #79588: reports of elevated production memory usage in Next.js 14 and 15 following App Router best practices, including
revalidatePathusage similar to this site’s pattern. A Node.js version downgrade was independently raised as a mitigation attempt in this thread as well. - 2026, discussion #88603: a report against Next.js 16.1.6 where
cacheMaxMemorySize: 0, the documented setting to disable the in-memory cache entirely, was tried and had no measurable effect on continuous memory growth. - 2026, issue #90433: a standalone-output OOM report against Next.js 16.0.10. A Vercel engineer investigated, showed that forcing garbage collection stabilized memory around 295MB, attributed the apparent growth to a bounded retention pattern in Node’s undici fetch implementation, and closed the issue as not a Next.js-specific leak. Not every high-memory report in this space is a genuine unbounded leak; some resolve to GC timing under sustained load.
- 2026, issue #92287: filed April 3, 2026, against Next.js 16.2.2, one patch version ahead of what this site was running, reproducing rapid unbounded memory growth (95MB baseline to 3.43GB over three minutes, ending in an out-of-memory crash) under sustained traffic against many unique request paths, in standalone mode with Cache Components enabled. A commenter on the same thread reported the identical trigger independently: unbounded growth and OOM immediately after migrating from
unstable_cachetouse cache, an explicit opt-in caching decision on Next.js 16, with no other changes to the app.
The last one matches closely in both version and timing: this wasn’t an old, patched bug. It was an active, currently-reported failure on close to the exact release and timeframe this site was running.
Diagnosing the cause: what the standalone toggle ruled out
output: 'standalone' was tried, removed, and tried again during troubleshooting, and the crash pattern persisted regardless of that setting. That’s a meaningful clue, because the most recent GitHub reports (#90433, #92287) describe a leak specific to standalone’s handling of streamed fetch responses. If that had been the sole mechanism here, toggling standalone off should have resolved it, since non-standalone builds don’t share that code path. It didn’t.
That points instead at the older, version-independent mechanism: the Data/ISR cache that underlies both standalone and non-standalone builds equally, growing against an unbounded, explicitly-cached key space. This is the best available explanation given the symptoms and the documented pattern it matches, not a confirmed diagnosis. The only hard data from that period is a single PM2 snapshot showing about 486MB of heap in use at 74% of the configured limit, and a matching /proc/PID/status RSS reading of roughly 985MB. Both are real numbers, but both are a single point in time, not a growth curve. No profiler trace or sequence of heap snapshots exists to confirm the mechanism directly.
A likely compounding factor: the Node.js version
The server was found running Node.js v25.6.1, a non-LTS “Current” release, at the time of the crashes. Node 22 was in Maintenance LTS and Node 24 was Active LTS at the time; production workloads have no real reason to run a Current release over an LTS one, and Current releases carry a materially higher risk of exactly this kind of instability.
This lines up with the public reports cited above: in the #68578 thread, one developer reported resolving a similar production memory leak by downgrading Node.js to 20.15.1. In the #79588 thread, a commenter independently raised a Node.js downgrade as a mitigation path for the same symptom.
Multiple factors likely contributed rather than one clean root cause: an unbounded, explicitly-cached route space stacked on top of a non-LTS Node runtime that itself has a documented history of fetch-related retention issues. This doesn’t rule out the caching mechanism, the unbounded key space would have been a real problem on any Node version, but the Node version was likely amplifying it, and it’s worth checking before assuming the cause is purely architectural.
The official mitigation path, and why it’s not a small lift
Next.js does provide a real, sanctioned answer to “high-cardinality cache growing unbounded in process memory”: move the cache out of process memory entirely.
The mechanism is the cacheHandler API, which has existed longer than you might expect:
- v12.2: introduced as an experimental
incrementalCacheHandlerPathoption - v13.5.1 onward: became usable enough for community tooling to build around
- v14.1: stabilized and renamed to
cacheHandler - v16: extended with a separate
cacheHandlers(plural) option specifically for the newuse cachemodel
Configured correctly, with cacheHandler pointed at a Redis-backed implementation and cacheMaxMemorySize: 0 to disable the default in-memory tier, this would move exactly the kind of high-cardinality search-page cache this site had into Redis instead of the Node process’s heap. That’s the documented answer to this specific problem.
Implementing it well is not simple, however. Vercel’s own official example for this is explicitly framed as “a starting point,” meant to be extended with your own eviction policy, error handling, and distributed tag coordination. A developer working on exactly this problem called the official documentation “insufficient” as early as July 2023, which is why third-party libraries like @neshca/cache-handler and its successor fortedigital/nextjs-cache-handler exist at all, to fill the gap between the official API surface and something production-usable.
The gap hasn’t fully closed for the newest caching model either. fortedigital/nextjs-cache-handler closed general Next.js 16 compatibility for the singular cacheHandler in October 2025, but a separate tracking issue specifically for cacheHandlers (plural) and use cache support remained open as of this writing. A developer in a live Next.js discussion thread reported the practical version of that gap directly: fortedigital/nextjs-cache-handler worked for Next 15+ caching features, but use cache itself didn’t route to Redis, it stayed in memory. A newer, narrower tool built specifically for the Cache Components model exists, but it’s new enough that it hasn’t seen wide production use yet. If you’re on the newest caching model wanting external Redis backing, the tooling is closer than it was, but not fully settled.
Why this failure mode is close to Vercel-exclusive-immune
On Vercel, fetch and unstable_cache calls in a Next.js App Router app are backed automatically by Vercel’s own Data Cache infrastructure, described in Vercel’s own announcement as globally distributed infrastructure scaffolded with no additional configuration. That’s a structurally different architecture from self-hosting, not just a managed convenience layer wrapped around the same underlying mechanism.
The Data Cache is regional: every region a function runs in gets its own cache, but that cache lives inside Vercel’s infrastructure, not inside the Node process executing the function. It persists across deployments, has a defined per-project storage limit, evicts least-recently-used entries once that limit is reached, and has a dedicated dashboard for monitoring cache size and eviction activity. External storage, a real enforced limit, visible eviction, monitoring, every one of those is exactly what was missing from the self-hosted scenario this article describes. A self-hosted Next.js process defaults to an in-memory LRU with a 50MB target size that, per the citations above, isn’t reliably enforced in practice, with no dashboard warning anyone it’s happening, only the process quietly growing until PM2 or the kernel notices.
That’s the structural reason this specific failure mode is close to nonexistent on Vercel by default: the cache was never competing with the function’s own memory for space, because it was never stored there in the first place. This isn’t unique to this one bug. A separate GitHub discussion shows a developer running the same App Router caching APIs self-hosted, asking essentially the question this article answers: how do you share the data and full route caches across multiple instances without either running a single point of failure or opting out of caching entirely? On Vercel, that problem doesn’t need solving, it’s built into the platform. Off Vercel, solving it is the developer’s job, which is exactly why Vercel’s own self-hosting documentation treats a custom cache handler as close to a requirement rather than an edge-case optimization.
Worth being precise about the boundary here: this doesn’t mean Next.js apps on Vercel are immune to memory issues in general, functions can still leak, and version-specific bugs still get reported there too. What it does mean is that the specific mechanism in this article, an in-memory cache accumulating without bound inside a self-managed process, is a self-hosting-specific failure mode almost by construction, since Vercel’s architecture removes the assumption that makes it possible in the first place.
Could this have been solved without migrating?
Yes, most likely. Two paths existed that didn’t require leaving Next.js:
- Externalize the cache. Implement
cacheHandleragainst Redis, setcacheMaxMemorySize: 0, and let cached search results live outside the Node process entirely. This is the documented, sanctioned fix for this exact symptom. - Bound the cardinality instead. Round geographic radius into fixed bands, normalize and bucket free-text queries, or simply stop caching long-tail one-off searches and only cache the frequently-repeated ones. This keeps the key space finite without touching the caching architecture at all.
A third option, moving to Vercel and letting its Data Cache absorb the problem structurally, was available too but wasn’t the right fit here for reasons unrelated to this specific bug, covered below.
Neither of the first two was attempted before the migration, and in hindsight, either one probably would have worked. The crash was not unsolvable within Next.js.
Why the migration happened anyway
The fix and the migration converged for a specific reason: option 1 above, done properly, means building a real Redis-backed caching layer sitting next to Next.js, handling eviction, tagging, and error cases yourself, because the official tooling is explicitly a starting point rather than a finished answer. Once that infrastructure is being built regardless, Next.js stops buying much on top of it for a server-rendered content site with no meaningful client-side interactivity requirements.
The memory problem was one input into the migration decision, not the only one. The others were independently valid and didn’t require a crash to justify: a major-version upgrade treadmill (three caching-model rewrites in three years, each requiring nontrivial migration work of its own), a larger dependency and CVE surface than a template-rendering layer needs to carry, and a preference for running code closer to the actual target runtime instead of through a build and rendering abstraction. The memory bug didn’t force the migration by itself. It justified the infrastructure that, once built, turned the rest of the migration into a much smaller step.
Where things stand now
Redis now owns 100% of the caching decisions for this site’s pages, with a straightforward LRU eviction policy: infrequently visited pages age out under memory pressure, while commonly searched pages get refreshed and re-cached constantly by ordinary traffic, keeping the working set naturally bounded by actual usage rather than by an arbitrary TTL against an unbounded key space.
The result has been a stable, flat memory profile with no crashes and no maintenance-mode restarts since: roughly 600-700MB total across two PM2 cluster workers, around 300-350MB per worker, holding steady regardless of how many unique searches accumulate.
Caching architecture comparison
| Architecture | Cache location | Behavior under unbounded key space |
|---|---|---|
| Next.js default in-memory cache (self-hosted) | Node process heap | Grows with unique keys; documented cap not always enforced in practice |
Next.js + custom cacheHandler (Redis, self-hosted) | External store | Bounded by Redis’s own memory policy; requires building eviction/tagging yourself |
| Next.js on Vercel (Data Cache) | External, Vercel-managed infrastructure | Bounded by a per-project storage limit with automatic LRU eviction and monitoring, built in |
| Express + Redis (current) | External store | Same externalization, without a framework-managed cache layer or version-to-version caching model changes |
Frequently asked questions
Was this actually fixed in Next.js 16?
Partially. Next.js 16’s Cache Components model made caching opt-in everywhere, which protects any route that doesn’t explicitly request caching. It does not protect a route that explicitly opts in, like this one did, against an unbounded key space. Separately, currently open issues (as of versions matching what this site was running) show unrelated memory growth in standalone-mode fetch handling, independent of caching configuration entirely.
Could this have been fixed without migrating off Next.js?
Most likely yes, either by externalizing the cache to Redis via Next’s cacheHandler API, or by bounding the cache key space (normalizing queries, rounding geographic radius) instead of caching every unique combination indefinitely. Neither was attempted before the migration decision was made.
Is this specific to geographic search routes?
No. Any route pattern that generates a large or unbounded number of unique cache keys from user input, free-text search, arbitrary filter combinations, per-user personalization, is exposed to the same mechanism. Geographic search is simply a common, high-cardinality example.
Does this mean Next.js has a memory leak?
Not in the sense of a single identifiable bug, and not every high-memory report in this space turns out to be a genuine unbounded leak, some resolve to GC timing under sustained load rather than true unbounded growth. What is well documented is that Next.js’s cache internals have historically struggled to enforce their own documented memory limits under sustained high-cardinality load, a pattern reported from 2021 through builds released in spring 2026, alongside a separate, confirmed class of leak specific to standalone-mode caching with streamed fetches.
Does self-hosting make this worse than deploying on Vercel?
Yes, structurally. Vercel’s Data Cache is external infrastructure the cache never lives inside the function’s own process memory, so this specific failure mode is close to nonexistent there by default. Self-hosting removes that guarantee entirely, which is why Next’s self-hosting documentation treats a custom cache handler as close to a requirement rather than an optional optimization. See the dedicated section above for the specifics.
How do you tell a real Next.js memory leak from a false one?
Force garbage collection and see if memory stabilizes. In one well-documented case (issue #90433), a Vercel engineer showed that a reported “unbounded” leak actually settled around 295MB once GC was manually triggered under load, the apparent growth was bounded retention in Node’s fetch implementation, not a true leak, and the issue was closed on that basis. A genuine leak keeps climbing even after GC runs and after load stops; a GC-timing artifact stabilizes once garbage collection is forced or given time to catch up. Without that check, the two look identical from a memory graph alone.
Does the Node.js version matter here?
Likely yes, as a compounding factor rather than the root cause. This deployment was running Node.js v25.6.1, a non-LTS “Current” release, at the time of the crashes, and multiple independent public reports of similar Next.js memory symptoms describe resolving or mitigating them by downgrading to an LTS Node version. An unbounded, explicitly-cached route space is a real problem on any Node version, but running a Current release instead of an LTS one is a documented way to make an existing memory problem worse, and it’s worth ruling out before assuming the cause is purely architectural.
Related reading
This wasn’t the only Next.js-specific issue this migration surfaced. A separate, unrelated bug in how Next.js handles CMS-embedded social scripts on client-side navigation is covered in Why Instagram, TikTok, Twitter, and Other Social Embeds Break on Next.js Route Changes.