Next.js Memory Leak on Self-Hosted Search Routes: A Case Study
On April 6, 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.
What follows is the mechanism, reconstructed from the actual repository and the public bug reports it lines up with: what the site cached, why the cache had no ceiling, which Next.js releases made it worse, and how to tell whether your own deployment is exposed.
Rundown
- The app exposed 26 dynamic
[query]route families across 51 page files, each combining free-text search with an unvalidated numeric radius. The number of reachable URLs had no upper bound. - Every one of those pages carried
export const revalidate = 600, and the app definedgenerateStaticParamsnowhere, so each unique URL was rendered and cached on demand. - The cached render path also issued an outbound
fetch()to Google’s geocoding API with the raw user query embedded in the URL, giving a second unbounded key space alongside the first. - Memory climbed until it hit a 1GB PM2 cap, which triggered a restart and a maintenance screen for whoever was mid-request. A capture taken on the morning of the cutover shows 874.6MB of resident memory after 93 minutes of uptime, against a live heap of only 250MB.
- The server was already on Node.js v24.14.1, the Active LTS release at the time, so the usual advice to move off a Current release did not apply and would not have helped.
- From December 2025 to March 19, 2026 the site ran Next.js 16.0.x and 16.1.6, releases predating three separate memory fixes. All three first shipped together in 16.2.0.
- Next.js changed its caching defaults twice (Next 15 in 2024, Next 16 in 2025) to reduce this class of problem, and neither change protects a route that explicitly opts back into caching, which these did.
- Two mitigations existed inside the framework: move the cache out of process with
cacheHandler, or bound the key space. Both are real work, and both converge on close to the architecture the migration produced. - Vercel’s managed cache layers hold entries outside the function’s process memory, so the same code deployed there is largely immune to this particular mechanism.
The route space that generated the keys
The site is a raw milk farm directory with listings across 60+ countries. Its search surface is not a fixed set of pages. The App Router tree carried 26 distinct [query] families:
src/app/{a2,a2/cow,buffalo,butter,camel,cheese,colostrum,cow,cow/kefir,
cream,donkey,eggs,goat,goat/kefir,icecream,kefir,mare,rawmi,
search,sheep}/[query]
src/app/social/{facebook,instagram,tiktok,twitter,youtube}/[query]
src/app/content/search/[query]
Most families existed twice, as [query] and as [query]/[distance], for 51 page files in total. Alongside them sat near/[lat]/[lng] taking arbitrary coordinates and browse/[country]/[state]/[city].
The radius segment is where the cardinality becomes provable rather than rhetorical. The only validation applied to it:
// src/app/goat/[query]/[distance]/page.tsx
const distance = (await params).distance
if (isNaN(Number(distance))) {
// bail out
}
Any numeric string passes. /goat/austin/25, /goat/austin/26, and /goat/austin/999999 are three separate routes that each render and each occupy a cache entry, even though the page only branches on 25 and 50 when picking a map zoom level. There is no allowlist anywhere in the chain.
Live access logs from the week of the migration confirm this was not theoretical. Among the requests served: /camel/Lebanon,%20NH/300 and /colostrum/Falls%20Village/200, radius values well outside the three the interface offers. The same logs show the query axis being enumerated globally, from /cheese/Amstetten,%20Lower%20Austria to /browse/United%20Arab%20Emirates/Dubai/Dubai, alongside scraper traffic submitting search strings in Chinese and Vietnamese that the resolver rejected as junk. Every one of those rejections still cost a route render.
The query segment has a length cap of 45 characters and some character-class rejection for junk input, and is otherwise free text passed through to a geocoder. Multiply free text against unbounded numerics across 26 families and the reachable URL count is not large, it is unbounded.
What the cache stored
Every one of those 51 pages carried the same export:
export const revalidate = 600 // invalidate every 10 minutes
Two facts about the surrounding code turn that line into the mechanism.
First, the app defined generateStaticParams in zero files. Nothing was pre-rendered at build time, and dynamicParams was left at its default, so every distinct URL that arrived was rendered on demand and its output stored under the route cache with a 10-minute freshness window.
Second, the render path made an outbound network call keyed by user input. The chain runs getMapSearchCategory → searchResolver → geocoder, ending here:
// src/util/map-getters/geocoder.js
const getFromGoogleURL =
`https://maps.googleapis.com/maps/api/geocode/json?address=${query}&key=${process.env.GEOCODER_KEY}`
gotGeocoderResponse = await fetch(getFromGoogleURL, { signal: controller.signal })
The user’s search string is interpolated directly into the request URL. Next.js keys its fetch cache on that URL, so unique queries produced unique fetch entries in addition to unique route entries. Two unbounded key spaces, stacked, in one process.
A time-to-live bounds how long any single entry lives. It does nothing to bound how many entries 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.
Vercel now documents this trap directly. The remote caching directive reference warns that cache keys with mostly unique values per request drive cache utilization to near zero, and its worked example caches on product category while deliberately excluding a price filter, on the grounds that category has few unique values and price has many. That is the distinction this route space never made.
One late change is worth recording, both for what it did and for what its aftermath rules out. On March 29, 2026, eight days before the cutover, a commit added request logging to the search resolver:
// src/util/map-getters/search-resolver.js
const headersList = await headers()
const ip = headersList.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'
const userAgent = headersList.get('user-agent') || 'unknown'
Every consumer of those two variables is a commented-out console.log. The values were never used. But headers() is a request-time API, and per Next’s own reference, using it “will opt a route into dynamic rendering.” The call is what counts, not the use of its result.
The knock-on effect reaches further than the route cache. Under the default fetchCache behavior, Next.js caches fetches reachable before a request-time API and skips caching those discovered after one. headers() runs at the top of the resolver, ahead of the geocoding call, so the geocoder fetch stopped being cached at the same moment the route did.
The blast radius was partial in a specific way. getMapSearchCategory handles US state names and abbreviations in an earlier branch that returns before the resolver is ever called, so /goat/texas kept caching normally. Every other query shape, meaning cities, postal codes, and arbitrary text, took the resolver path. The bounded part of the key space stayed cached; the unbounded part went dynamic.
That produces a testable expectation, and the April 6 capture settles it. If cache accumulation were the whole story, removing both caches from the unbounded traffic should have flattened the memory curve. Eight days after the resolver change, the process was still reaching 874.6MB in 93 minutes with a 250MB heap. Dynamic rendering does not reduce memory, it removes a cache and raises per-request work: with nothing cached, every request ran a full render, a database query, and a live call to Google’s geocoding API, so outbound fetch volume rose sharply. That is the workload shape described in #90433 and #90898, where retention is measured per fetch rather than per cache entry.
Both mechanisms were present, and the evidence points away from the cache being the dominant one at the end. What survives without qualification is the shape of the input: an unbounded key space driving both an unbounded cache and an unbounded volume of outbound requests, on a single process with a 1GB ceiling.
The version window
The repository’s dependency history shows what the site was running and when:
| Date | Next.js version |
|---|---|
| 2025-01 to 2025-11 | 15.1.x, then 15.2.4 |
| 2025-12-03 | 16.0.3, then 16.0.7 |
| 2026-02-02 | 16.0.10 |
| 2026-03-15 | 16.1.6 |
| 2026-03-19 | 16.2.0 |
| 2026-03-24 | 16.2.1 |
revalidate = 600 was committed on July 26, 2025, so the caching configuration predates the entire Next.js 16 line.
That timeline matters because three separate memory fixes merged into Next.js during early 2026 and none of them reached a stable release until 16.2.0 on March 18, 2026:
- #89040, merged January 26, forces LRU cache items to a minimum size of 1 so zero-sized entries cannot accumulate without bound.
- #88577, merged February 10, registers the second tee’d response body clone with the
FinalizationRegistry. - #90771, merged March 2, skips a back-forward cache write that has no window to write to.
The site spent December through March 19 on releases carrying none of them, and ran 16.1.6 from March 15, the exact version and configuration that issue #90898 documented with a heap dump. It moved to the first fixed release eighteen days before the cutover.
Which releases carry the fixes
Checking each fix commit for ancestry against the published release tags gives a clean line:
| Release | #89040 | #88577 | #90771 |
|---|---|---|---|
| 16.0.1 | no | no | no |
| 16.0.10 | no | no | no |
| 16.1.0 | no | no | no |
| 16.1.6 | no | no | no |
| 16.2.0 | yes | yes | yes |
| 16.2.1 | yes | yes | yes |
| 16.2.2 | yes | yes | yes |
| 16.3.0 | yes | yes | yes |
If you are on any 16.0.x or 16.1.x release, upgrading to 16.2.0 or later is the cheapest thing you can do before investigating anything else.
One leak in this cluster is still open. Issue #92287, filed April 3, 2026 against 16.2.2, describes unbounded arrayBuffers growth in standalone mode with Cache Components enabled. Its candidate fix, PR #94238, remains unmerged as of August 2026, and 16.3.0 shipped on August 3 without it.
Memory growth and the PM2 restart cap
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 characterize: CPU and network both looked fine even as memory climbed steadily toward the ceiling in the background.
Two PM2 captures survive from April 6, 2026, taken 23 hours apart on either side of the cutover. The first, at 00:35, shows the Next.js process:
│ id │ name │ mode │ uptime │ ↺ │ mem │
│ 0 │ GetRawMilk.com │ fork │ 93m │ 186 │ 874.6mb │
874.6MB of resident memory after 93 minutes of uptime, against a 1GB cap. The same capture’s runtime metrics show where that memory was not:
│ Used Heap Size │ 250.63 MiB │
│ Heap Size │ 382.98 MiB │
│ Heap Usage │ 65.44 % │
A 250MB live heap inside an 874MB process leaves roughly 490MB unaccounted for by JavaScript objects. That gap is external allocation: buffers, streams, and native memory held outside the V8 heap. A route cache full of rendered pages would show up in the heap. This did not, which points at the retention class described in #90898 and #92287 rather than at accumulated cache entries.
The process config that produced it:
// ecosystem.config.js
{
name: 'GetRawMilk.com',
script: 'node_modules/.bin/next',
args: 'start',
instances: 1,
exec_mode: 'fork',
max_memory_restart: '1G',
}
With no limit set, the process grew until the server ran out of memory outright and crashed. max_memory_restart: '1G' turned that into a managed failure instead of an unmanaged one: the process climbed toward 1GB, PM2 killed and restarted it, and any user whose request landed during that restart window saw a maintenance screen for a few seconds. A symptom being treated, not a cause being fixed.
Two details in that config matter later. The app ran next start, not output: 'standalone'. And it ran a single fork instance, not a cluster.
The restart counter is worth reading carefully, because it measures less than it appears to. PM2 resets it to zero on pm2 kill, on a server reboot, and whenever processes are started by hand, all of which happened repeatedly during runtime upgrades and deploys. A reading of 186 is 186 restarts since the last reset, not a crash total, and it also mixes memory-cap restarts together with ordinary deploy restarts. The number carries one piece of information reliably: between resets it only ever went up.
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. CPU and memory are separate resources, so this is adjacent evidence rather than direct evidence about the memory mechanism, 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 the Client Router Cache became uncached by default. The release notes attribute the change to user feedback and to how the old heuristics interacted with Partial Prerendering and third-party libraries using
fetch. - Next.js 16 (October 21, 2025): Cache Components and the
use cachedirective extended opt-in caching to the component and page level. The Next.js 16 announcement states that caching with Cache Components “is entirely opt-in.”
Neither default helped here, and the reason is worth being exact about. cacheComponents was never enabled in this app’s config, so the Next 16 opt-in model was not the mechanism in play. What was in play was the older route-segment revalidate export, which survives into 16 and means precisely what it has always meant: cache this route’s output. The new defaults protect routes that don’t ask to be cached. These asked, on every dynamic search page, which is a completely reasonable thing to want for a search feature. Opting into caching over an unbounded key space reproduces the high-cardinality growth problem regardless of what the framework’s default is.
Public reports of the same pattern, 2021 to 2026
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.” A later commenter in the same thread posted a screenshot showing the cache had 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. A Vercel team member closed it the following day, on the grounds that it “seems to be a Node issue and not a Next.js issue.”
- 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, with one production server reported at roughly 9GB of RAM. Closed July 2025 after the reporter self-resolved by upgrading to 15.3.3 and applying memory-optimization config flags. - 2026, discussion #88603: an unanswered report of OOM crashes in Docker and Kubernetes, opened January 2026 and updated through 16.2.x, where a commenter tried both disabling the in-memory cache with
cacheMaxMemorySize: 0and capping it at 128MB, reporting that “neither had any impact.” - 2026, issue #90433: a standalone-output OOM report against 16.0.10. A Vercel engineer forced garbage collection under a 10,000-request load, showed memory topping out around 295MiB and settling back to roughly 150MiB afterward, and attributed the residue to bounded performance-entry retention in Node’s undici fetch implementation. Closed March 3, 2026 on that basis, over the reporter’s objection that the same workload still exhausted memory in production.
- 2026, issue #90898: a confirmed leak in 16.1.6 standalone, with a heap dump showing 977MB retained across 183
JSArrayBufferDataobjects andarrayBuffersclimbing about 5MB/s under ordinary traffic. Forcing garbage collection did not reclaim it. Fixed in canary and closed March 9, 2026. - 2026, issue #92287: filed April 3, 2026 against 16.2.2, reproducing growth from a 95MB baseline to 3.43GB of RSS over three minutes under sustained traffic against many unique request paths, in standalone mode with Cache Components enabled. Still open.
The 2021 report and the 2026 reports describe different things, and conflating them is the most common mistake in this space. The 2021 complaint is about a cache that holds more than its documented target. The 2026 reports are about buffers retained by streamed fetch handling in standalone mode. This site was exposed to the first by architecture and to the second only if standalone was in play.
What the standalone toggle ruled out
output: 'standalone' was tried, removed, and tried again during troubleshooting, and the final committed config carries no output key at all: the app shipped on next start. The crash pattern persisted regardless of that setting, which is itself a clue, because the most recent reports (#90433, #90898, #92287) all describe leaks specific to standalone’s handling of streamed fetch responses.
The author of #92287 ran the control that makes this decisive. Alongside the standalone reproduction, they ran the same load against a plain next start build: memory grew substantially during the run and then recovered once traffic stopped, while the standalone server climbed until it died. A leak that resolves itself when standalone is switched off is not the leak that survived switching standalone off here.
So #92287 is not the matching mechanism for this case, despite being the closest match on version and timing. What it establishes is the state of the 16.2.x line in this window, and one detail that matters more than the leak itself: it reproduced on Node.js 25.1.0.
That leaves two mechanisms that fit. The route cache grew against an unbounded, explicitly-cached key space, which is version-independent and affects standalone and non-standalone builds equally. And the geocoding fetches produced an unbounded set of distinct request URLs, which is the exact shape of workload the undici retention reports describe. The two compound, because the same user input drove both.
The final two weeks weigh against the first being dominant. Once the resolver change removed both caches from the unbounded traffic and memory kept climbing anyway, the growth that remained had to be coming from per-request retention rather than from stored entries.
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, and both are a single point in time rather than a growth curve. No profiler trace or sequence of heap snapshots exists to confirm the mechanism directly, which is a gap the next section exists to close for anyone still in the middle of this.
The Node.js version
The most common advice given to anyone reporting this symptom is to move to an LTS release of Node.js. This deployment was already on one.
The PM2 process description captured on the morning of the cutover records the interpreter directly:
│ interpreter │ /root/.nvm/versions/node/v24.14.1/bin/node │
│ node.js version │ 24.14.1 │
Node 24 entered Active LTS on October 28, 2025 and remained there until October 2026, so v24.14.1 was the current Active LTS release at the time of the crashes. The recommended configuration was in place, and the process still climbed to 87% of its memory ceiling in 93 minutes.
That matters for how the public reports should be read. In #68578, one developer reported fixing a similar production leak by downgrading to Node 20.15.1, and the original reporter confirmed it worked for them too. In the same exchange, that reporter noted 22.6.0, then months away from becoming LTS, was affected as well. In #79588, a developer tried the same 20.15.1 downgrade and reported it did not isolate their problem. The underlying Node.js fetch memory issue those threads point at was itself closed in October 2024.
Running an LTS release is still the right default. It is not a fix for this class of problem, and this deployment is a direct counterexample to treating it as one. The relevant variable was the shape of the workload, not the runtime channel.
How to diagnose this on your own server
The reports above contain a usable methodology, assembled here because the absence of it is what made this case take months to characterize.
Separate a real leak from garbage collection lag. Run the process with the collector exposed and force a collection under load:
node --expose-gc server.js
// call at the end of a request handler, temporarily
global.gc()
Then drive traffic and watch what memory does after collection runs:
ab -n 5000 -c 100 http://127.0.0.1:3000/
# let it settle, then repeat
ab -n 5000 -c 100 http://127.0.0.1:3000/
This is the exact procedure that resolved #90433 as bounded retention rather than a leak: memory stopped near 295MiB and fell back to roughly 150MiB once traffic stopped. It is also the procedure that confirmed #90898 as a genuine leak, where arrayBuffers kept climbing about 5MB/s and never came back even with forced collection. Memory that stabilizes after a forced collection is a timing artifact. Memory that keeps climbing through one is a leak.
Watch the right counter. process.memoryUsage() splits the number that matters:
const { heapUsed, arrayBuffers, rss } = process.memoryUsage()
Growth concentrated in arrayBuffers points at retained buffers from streamed fetch handling, which is the 2026 standalone cluster. Growth in heapUsed against a stable arrayBuffers points at retained objects, which is the cache-accumulation pattern. A single RSS reading distinguishes neither, which is why the one snapshot from this deployment could not settle the question.
Take heap snapshots, not readings. The heap dump in #90898 named its own culprit: 977MB retained across 183 JSArrayBufferData objects, with the retainer chain visible. Two snapshots taken minutes apart under load, compared, will name yours.
Drive it with unique paths, not one path. A load test against a single URL exercises a cache hit and will show nothing. The reproduction in #92287 works because it requests many distinct paths, which is what a real search surface receives:
for i in $(seq 1 5000); do
curl -s "http://127.0.0.1:3000/goat/city-$i/25" > /dev/null
done
If memory tracks the count of distinct URLs requested, the key space is the problem, and no framework upgrade will fix it.
The official mitigation path
Next.js provides a sanctioned answer to a high-cardinality cache growing unbounded in process memory: move the cache out of process memory.
The mechanism is the cacheHandler API, which has existed longer than you might expect:
- v12.2.0: introduced as an experimental
incrementalCacheHandlerPathoption - v13.4.0: gained
revalidateTagsupport and standalone output support - v14.1.0: stabilized and renamed to
cacheHandler - v16.0.0: joined by a separate
cacheHandlers(plural) option for theuse cachemodel - v16.2.0: extended to cover optimized-image cache entries
For a route-segment revalidate workload like this one, the singular option is the relevant one:
// next.config.js
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable the default in-memory tier
}
Setting cacheMaxMemorySize: 0 is the half people skip. Without it the in-process LRU stays in front of the external store and keeps growing.
Implementing the handler well is not simple. Next’s own self-hosting guide frames its handler example as a starting point for production use, naming durable storage, eviction policies, error handling, and distributed tag coordination as the parts left to the reader. A developer working on this exact problem called the official documentation “insufficient” in July 2023, which is why third-party libraries like @neshca/cache-handler and its successor fortedigital/nextjs-cache-handler exist at all.
The picture for the newest caching model has improved since this migration. Next.js documents that cacheHandler (singular) “is not used by 'use cache' directives.” The plural cacheHandlers option covers those instead:
// next.config.ts
const nextConfig = {
cacheComponents: true,
cacheHandlers: {
default: require.resolve('./cache-handlers/default-handler.js'),
remote: require.resolve('./cache-handlers/remote-handler.js'),
},
}
Paired with the 'use cache: remote' directive, self-hosted external backing for Cache Components is now an officially documented path with a worked Redis example rather than a gap. Third-party tooling still trails: the fortedigital/nextjs-cache-handler tracking issue for cacheComponents and use cache support, opened November 19, 2025 and tagged as a 3.0.0 candidate, remains open as of August 2026.
Bounding the key space instead
The cheaper mitigation attacks the cardinality rather than the storage. For this route space it meant three concrete changes, none of which require touching the caching architecture.
Constrain the radius to an allowlist instead of accepting any number:
const ALLOWED_RADII = [25, 50, 100] as const
const radius = Number(distance)
if (!ALLOWED_RADII.includes(radius as typeof ALLOWED_RADII[number])) {
notFound()
}
That alone collapses an infinite axis to three values.
Normalize the query before it becomes a cache key, so that Austin, TX, austin,tx, and Austin , TX are one entry rather than three:
const normalized = decodeURIComponent(query)
.toLowerCase()
.replace(/\s*,\s*/g, ', ')
.replace(/\s+/g, ' ')
.trim()
And round coordinates for the near/[lat]/[lng] routes, since three decimal places is roughly 110 metres and nobody needs a distinct cached page per metre:
const lat = Number(rawLat).toFixed(3)
const lng = Number(rawLng).toFixed(3)
Next’s own caching documentation now recommends this approach directly, advising that cache keys be built from the dimension with fewer unique values and the rest filtered in memory.
Choosing between the mitigations
| Situation | Do this |
|---|---|
| On 16.0.x or 16.1.x | Upgrade to 16.2.0+ first; three memory fixes land there |
| Key space is provably finite, memory still grows | Suspect the framework; take heap snapshots before changing architecture |
| Key space is unbounded, single instance | Bound the key space; it is far less work than a cache handler |
| Key space is unbounded, multiple instances or containers | Implement cacheHandler against Redis with cacheMaxMemorySize: 0 |
On Cache Components with use cache | Use cacheHandlers (plural) plus 'use cache: remote' |
| Building the Redis layer anyway, and the app is content-driven | Evaluate whether the framework is still earning its place |
Why Vercel deployments avoid this failure mode
On Vercel, App Router caching is absorbed by managed infrastructure rather than by the function’s own memory. Vercel announced the Data Cache in February 2023 as “framework-defined, global caching infrastructure with zero configuration.” That is a structurally different architecture from self-hosting, not a managed convenience layer over the same mechanism.
The naming has since moved on, and the distinction matters for anyone on a current release. Vercel now scopes the Data Cache to Next.js 14 and below, and routes Next.js 15 and 16 to Runtime Cache instead, with 'use cache: remote' as the Next.js 16 entry point. Both are regional: every region a function runs in gets its own cache, and that cache lives inside Vercel’s infrastructure rather than inside the Node process executing the function. Both persist across deployments, carry a fixed storage limit, evict least-recently-used entries once that limit is reached, and report cache size and eviction activity in a dashboard. Runtime cache usage is metered and billed, which is what the guarantee costs.
External storage, an enforced limit, visible eviction, and monitoring are precisely the four things missing from the self-hosted setup described here. A self-hosted Next.js process defaults to an in-memory cache with a 50MB target that, per the citations above, has not always been enforced in practice, with no dashboard warning anyone it’s happening, only the process quietly growing until PM2 or the kernel notices.
The structural reason this failure mode is close to nonexistent on Vercel by default comes down to one thing: the cache was never competing with the function’s own memory for space, because it was never stored there. The problem generalizes beyond one bug. A separate GitHub discussion from 2023 shows a developer running the same App Router caching APIs self-hosted, asking how to share the data and full route caches across multiple instances without running a single point of failure or opting out of caching entirely. The accepted answer points at the custom cache handler API.
The boundary is narrower than “self-hosting is riskier.” Next.js apps on Vercel can still leak, functions still have memory limits, and version-specific bugs get reported there too. The specific mechanism in this case, an in-memory cache accumulating without bound inside a self-managed long-lived process, is self-hosting-specific almost by construction.
Next’s own recommendation is also scoped more tightly than it is usually reported. The self-hosting guide says a single next start instance with persistent local disk “works automatically,” and reserves the cache-handler recommendation for multi-instance, containerized, or ephemeral-compute deployments where each pod otherwise holds its own copy of the cache. This deployment ran a single fork instance with persistent disk, which is the configuration the docs say needs no handler. The unbounded key space is what moved it out of that category.
Why the migration happened anyway
Neither mitigation was attempted before the migration, and in hindsight either one probably would have worked. The crash was solvable within Next.js.
The fix and the migration converged for a specific reason. Externalizing the cache, done properly, means building a real Redis-backed caching layer next to Next.js, handling eviction, tagging, and error cases directly, 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 among several. 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 dependency surface carrying MongoDB, Leaflet, TipTap, Square, Recharts, and a globe renderer behind a template-rendering job, and a preference for running code closer to the actual target runtime instead of through a build and rendering abstraction. The memory bug justified the infrastructure that, once built, turned the rest of the migration into a much smaller step.
The replacement: a two-key SWR cache in Redis
The Express replacement inverted the failure. Instead of an unbounded cache inside a bounded process, it uses a bounded cache outside the process.
Redis is configured with a hard ceiling and an eviction policy that cannot be exceeded:
maxmemory 1gb
maxmemory-policy allkeys-lru
That single line is what the Next.js setup never had. Under allkeys-lru, Redis evicts the least recently used key once it reaches 1GB, so the working set is bounded by actual traffic rather than by an arbitrary TTL against an unbounded key space. Long-tail one-off searches age out. Frequently searched pages get refreshed and re-cached by ordinary traffic and stay resident.
The middleware stores two keys per URL rather than one:
cotw:html:/path the rendered HTML, 24-hour backing TTL
cotw:fresh:/path a small sentinel, TTL = the freshness window (default 600s)
The sentinel is what makes it stale-while-revalidate. Three cases follow from whether each key exists:
- Sentinel present: serve the HTML immediately. Fresh hit.
- Sentinel expired, HTML present: serve the stale HTML immediately, and re-render in the background. Nobody waits.
- Neither present: render blocking. Only happens on first boot or after an explicit flush.
Background revalidations are deduplicated through an in-process Set, so a hundred simultaneous requests for the same expired path trigger one re-render rather than a hundred. The in-process state is a set of path strings for in-flight renders, which is bounded by concurrency rather than by the key space.
The second PM2 capture from April 6, taken 23 hours after the first and 55 minutes after the new process came up at 22:41, shows the replacement running:
│ id │ name │ mode │ uptime │ ↺ │ mem │
│ 0 │ GetRawMilk.com │ cluster │ 55m │ 1 │ 344.5mb │
│ 1 │ GetRawMilk.com │ cluster │ 55m │ 1 │ 327.8mb │
672MB across two workers against 874MB in one. Live heap fell from 250MB to 99MB and 65MB across the pair, active handles from 42 to 15, and event loop p95 latency from 4.35ms to about 1.3ms. Server-wide swap usage dropped from 1015MB to 351MB, which is the clearest single indicator that the machine had been under real pressure rather than merely holding a large cache.
The profile has stayed flat since, holding around 300-350MB per worker regardless of how many unique searches accumulate. The unbounded key space still exists, because the route patterns did not change. It just no longer lives in the process that has to stay alive.
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 50MB target not always enforced in practice |
Next.js + custom cacheHandler (Redis, self-hosted) | External store | Bounded by Redis’s own memory policy; requires building eviction and tagging yourself |
Next.js 16 + cacheHandlers and 'use cache: remote' | External store | Same externalization for Cache Components; documented interface, third-party tooling still catching up |
| Next.js on Vercel (Data Cache, Runtime Cache) | External, Vercel-managed infrastructure | Bounded by a fixed storage limit with automatic LRU eviction and monitoring, built in; usage billed |
| Express + Redis (current) | External store | Bounded by an explicit maxmemory with allkeys-lru; no framework-managed cache layer or version-to-version model changes |
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.