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({ status: 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."
Nothing yet. Notes filed here show up on this page and the section overview.
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.