These are the patterns that survive code review because each one looks like ordinary code. They only become leaks because of the fact from the overview: plugins and setup() run again for every SSR request inside a process that never restarts. Learn the table as a checklist you can run over a diff; the sections after it expand the ones that turn up most often in a layer's server code.
| # | Pattern | Why it leaks | Fix |
|---|---|---|---|
| 1 | Module-scope mutable state in runtime code: const seen: NuxtApp[] = [] in a plugin, let currentUser in a composable | The variable lives for the process and retains every request's objects; it also pollutes other requests | Per-request homes: nuxtApp.provide, useState, event.context. If a process-level cache is intended, bound it (LRU/TTL) and key it deliberately |
| 2 | Listeners registered per request on process-lifetime emitters: nitroApp.hooks.hook(...) inside a handler, process.on(...), dbClient.on(...) | Every request adds a closure that retains that request; MaxListenersExceededWarning is the early smell | Register once in a Nitro plugin; hookOnce; keep the returned unsubscribe function and call it |
| 3 | Timers started during SSR: setInterval in a universal plugin or composable | The plugin runs once per request; the intervals are never cleared | Make it client-only (.client.ts / import.meta.client), or tie it to a scope and clear it |
| 4 | Unbounded caches: hand-rolled Map keyed by URL or user; defineCachedEventHandler whose key includes arbitrary query params; swr route rules on a long-running node server with in-memory storage | The key space is unbounded, so memory grows with traffic rather than with data | getKey that normalises; maxAge + staleMaxAge; external storage (Redis/KV) in production; lru-cache with max and ttl |
| 5 | Vue effects created without an owner on the server: watch/watchEffect/computed at module scope, in a Nitro plugin, or after an await in a plain setup() (instance context lost) | An effect subscribed to a long-lived reactive source is retained by that source's dependency list — one more per request | Create effects synchronously inside components; wrap plugin-level effects in effectScope() and stop it; onScopeDispose |
| 6 | Singletons that should be per-app: Pinia created at module scope, one i18n / Apollo / TanStack QueryClient shared by all requests | State and caches accumulate across requests and leak between users | Create per nuxtApp inside the plugin (createPinia() per app, the way @pinia/nuxt does); dehydrate/hydrate per request |
| 7 | Caching rendered responses in a Map from render:response / beforeResponse | HTML plus payload per route retained forever | Bounded or external cache; use Nitro's cache layer instead |
| 8 | Streams and connections not closed: unconsumed fetch bodies, WebSocket peers kept in a global Map without removal on close, upstream requests not aborted when the client disconnects | Handles and buffers pile up outside the JS heap | body.cancel(); remove on close; an AbortController wired to event.node.req.on('close', …) |
| 9 | Per-request data stored in a global keyed by request id (Map<id, ctx>) and never deleted | Every request adds an entry; the delete is skipped on every error path | event.context, or a WeakMap keyed by the event object |
| 10 | Closures capturing event / nuxtApp inside defineCachedFunction | The cache entry retains the whole request that happened to populate it | Pass primitives in; never close over or cache request objects |
| 11 | Module setup side effects in long-lived dev processes: child processes, file watchers, servers started without close handling | Orphans accumulate on every restart | nuxt.hook('close', () => child.kill()) |
| 12 | Hooks registered inside hooks: nuxt.hook(...) inside a builder:watch callback | Listeners compound on every rebuild — dev-only, but it makes nuxt dev slow and eventually unstable | Register once at setup; use hookOnce |
| 13 | Prerender-time accumulation: collecting per-route data in a Map across thousands of routes | Grows with route count during nuxt generate until the build OOMs | Flush per route, or write to disk as you go |
The canonical leak, and the one interviewers use as a warm-up. It is also the security bug, because request two reads what request one wrote.
// module scope in the server bundle = one array for the whole process
const seen: unknown[] = []
let currentUser: User | null = null
export default defineNuxtPlugin(async (nuxtApp) => {
seen.push(nuxtApp) // retains every request's Vue app
currentUser = await $fetch('/api/me') // request 2 can read request 1's user
nuxtApp.provide('user', currentUser)
})
export default defineNuxtPlugin(async () => {
// useState lives on this request's nuxtApp and is serialised into this response only
const user = useState<User | null>('auth:user', () => null)
user.value = await $fetch('/api/me')
if (import.meta.server) {
// per-request counters belong on the event, not on the module
const event = useRequestEvent()
if (event) event.context.audit = { renderedAt: Date.now() }
}
})
The rule generalises: runtime code in a layer or module has no module-scope mutable state. const holding a frozen config object is fine. A Map, an array, a let, or a ref() is not, unless you are deliberately building a process-level cache — in which case see #4.
nitroApp.hooks.hook() returns an unsubscribe function, and calling it inside a request handler means you add a listener on every request that then holds that handler's closure — including its event.
export default defineEventHandler(async (event) => {
const nitroApp = useNitroApp()
// a new listener every request, each closing over this event
nitroApp.hooks.hook('afterResponse', () => {
track('report', event.path)
})
return buildReport(event)
})
export default defineNitroPlugin((nitroApp) => {
// registered once for the process; the event arrives as an argument
nitroApp.hooks.hook('afterResponse', (event) => {
if (event.path.startsWith('/api/report')) track('report', event.path)
})
})
Node prints MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 <event> listeners added once an emitter passes ten listeners. If that line appears in your logs under normal traffic, you have registration inside a request path — and the number in the message tells you how far it has already run. Do not silence it with setMaxListeners(0); that is deleting the smoke detector. Reproduce locally with a short load test and use process.getActiveResourcesInfo() to see what is still attached.
export default defineNuxtPlugin(() => {
// ⚠ on the server this creates one interval per request, forever
setInterval(() => refreshFeatureFlags(), 30_000)
})
Rename the file to app/plugins/poll.client.ts and the problem disappears, because the plugin no longer exists in the server bundle. If the value genuinely has to refresh on the server, refresh it from a Nitro plugin (one interval per process) and call unref/clearInterval in the close hook — or better, cache it with a TTL and let the next request refill it.
Nitro's defineCachedEventHandler defaults its key to the request path plus query. Include a cache-buster, a session id or a ?t= timestamp and every request becomes its own cache entry.
export default defineCachedEventHandler(async (event) => {
const { q, t } = getQuery(event) // `t` is a random cache-buster
return search(String(q ?? ''))
}, { maxAge: 60 }) // key includes every query param
export default defineCachedEventHandler(
async (event) => {
const { q } = getQuery(event)
return search(String(q ?? '').trim().toLowerCase().slice(0, 64))
},
{
name: 'search',
maxAge: 60, // seconds fresh
staleMaxAge: 300, // seconds served stale while revalidating
// the key is the ONLY thing that can grow: normalise it and bound its shape
getKey: (event) => {
const q = String(getQuery(event).q ?? '').trim().toLowerCase().slice(0, 64)
return q || 'empty'
},
},
)
The same reasoning applies to the hand-rolled version. If you must keep an in-process cache, use lru-cache with an explicit max and ttl rather than a bare Map, and in production point Nitro's cache storage at Redis or KV so the memory is someone else's problem and survives a restart.
The exact option names on Nitro's cache layer (maxAge, staleMaxAge, swr, name, group, getKey, varies) and how nitro.storage.cache is configured for Redis. Re-read the Nitro cache guide before quoting them.
Buffers show up in external/arrayBuffers, not heapUsed, so this class of leak is invisible if you only watch the JS heap.
export default defineEventHandler(async (event) => {
const controller = new AbortController()
// when the browser disconnects, stop the upstream request instead of finishing it
event.node.req.on('close', () => controller.abort())
const res = await fetch(upstream, { signal: controller.signal })
if (!res.ok) {
await res.body?.cancel() // an unconsumed body keeps its buffers alive
throw createError({ statusCode: res.status })
}
return res
})
The rest of the table follows the same three shapes. #5, #6 are "an object that should have been per-request was created once": an effectScope() you never stop, a createPinia() at module scope instead of inside the plugin, an Apollo or TanStack QueryClient shared by every user. #7, #9, #10 are "a process-lifetime container holding request objects": a Map of rendered HTML filled from render:response, a Map<requestId, ctx> whose delete is skipped on the error path, a defineCachedFunction whose arguments close over event. #11, #12, #13 are build-time and dev-time: a module that spawns a watcher or child process without nuxt.hook('close', …), a nuxt.hook registered inside a builder:watch callback so listeners double on every rebuild (use hookOnce), and a prerender collector that accumulates across ten thousand routes.
routeRules: { '/blog/**': { swr: 3600 } } is excellent on a platform with a real cache storage behind it. On the plain node-server preset with the default in-memory storage, every distinct path you ever serve stays in the process heap for the TTL. A crawler walking ?page=1…5000 will happily fill it. Configure a persistent driver for the cache storage mount, or bound the routes the rule applies to.
nuxtApp. Build, run npx autocannon -c 20 -d 30 http://localhost:3000/, and watch heapUsed from a health route climb and stay climbed after a forced GC.nitroApp.hooks.hook('afterResponse', …) inside an event handler. Hit it twelve times and find MaxListenersExceededWarning in the terminal.defineCachedEventHandler without getKey, load-tested with ?t=${Math.random()}. Then add the normalising getKey and re-run the same load; the heap should flatten.new Map(, setInterval(, process.on(, .hooks.hook( and let under server/ and app/plugins/. Justify every hit or fix it."First, module-scope mutable state in runtime code — a const seen = [] or a let currentUser in a plugin or composable. The plugin body runs per SSR request, so the array retains every request's objects and the let hands request one's user to request two, which makes it a data-leak as well as a memory leak. It moves to useState, nuxtApp.provide or event.context. Second, setInterval in a universal plugin: one timer per request that nothing ever clears. It becomes a .client.ts plugin, or a single interval in a Nitro plugin with a close handler. Third, unbounded cache keys — a hand-rolled Map keyed by full URL, or a defineCachedEventHandler whose default key includes arbitrary query params, so a crawler with a cache-buster turns every request into a cache entry. The fix is a getKey that normalises, a maxAge and staleMaxAge, and external storage in production. The tell for the listener variant is MaxListenersExceededWarning in the logs."
Three lifetimes
Process, request and client session — what lives in each, who owns it, and the one question that tells you where a value belongs.
Client-side leaks
The eleven ways a long-lived browser tab keeps hold of unmounted components — listeners, timers, observers, third-party instances, orphaned effects and growing global state — with the Nuxt and VueUse fixes.