Memory leaks

Server-side leaks

The thirteen ways a Nuxt/Nitro server holds on to request-lifetime objects, what each one looks like in production, and the before/after for the ones that actually happen.

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.

The catalogue

#PatternWhy it leaksFix
1Module-scope mutable state in runtime code: const seen: NuxtApp[] = [] in a plugin, let currentUser in a composableThe variable lives for the process and retains every request's objects; it also pollutes other requestsPer-request homes: nuxtApp.provide, useState, event.context. If a process-level cache is intended, bound it (LRU/TTL) and key it deliberately
2Listeners 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 smellRegister once in a Nitro plugin; hookOnce; keep the returned unsubscribe function and call it
3Timers started during SSR: setInterval in a universal plugin or composableThe plugin runs once per request; the intervals are never clearedMake it client-only (.client.ts / import.meta.client), or tie it to a scope and clear it
4Unbounded 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 storageThe key space is unbounded, so memory grows with traffic rather than with datagetKey that normalises; maxAge + staleMaxAge; external storage (Redis/KV) in production; lru-cache with max and ttl
5Vue 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 requestCreate effects synchronously inside components; wrap plugin-level effects in effectScope() and stop it; onScopeDispose
6Singletons that should be per-app: Pinia created at module scope, one i18n / Apollo / TanStack QueryClient shared by all requestsState and caches accumulate across requests and leak between usersCreate per nuxtApp inside the plugin (createPinia() per app, the way @pinia/nuxt does); dehydrate/hydrate per request
7Caching rendered responses in a Map from render:response / beforeResponseHTML plus payload per route retained foreverBounded or external cache; use Nitro's cache layer instead
8Streams 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 disconnectsHandles and buffers pile up outside the JS heapbody.cancel(); remove on close; an AbortController wired to event.node.req.on('close', …)
9Per-request data stored in a global keyed by request id (Map<id, ctx>) and never deletedEvery request adds an entry; the delete is skipped on every error pathevent.context, or a WeakMap keyed by the event object
10Closures capturing event / nuxtApp inside defineCachedFunctionThe cache entry retains the whole request that happened to populate itPass primitives in; never close over or cache request objects
11Module setup side effects in long-lived dev processes: child processes, file watchers, servers started without close handlingOrphans accumulate on every restartnuxt.hook('close', () => child.kill())
12Hooks registered inside hooks: nuxt.hook(...) inside a builder:watch callbackListeners compound on every rebuild — dev-only, but it makes nuxt dev slow and eventually unstableRegister once at setup; use hookOnce
13Prerender-time accumulation: collecting per-route data in a Map across thousands of routesGrows with route count during nuxt generate until the build OOMsFlush per route, or write to disk as you go

How it works

1 — Module-scope mutable state

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)
})

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.

2 — Listeners registered per request

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)
})
Gotcha· `MaxListenersExceededWarning` is the free leak detector

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.

3 — Timers started during SSR

app/plugins/poll.ts
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.

4 — Unbounded caches and cache keys

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

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.

Verify before the interview:

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.

docs ↗

8 — Streams and abandoned upstream work

Buffers show up in external/arrayBuffers, not heapUsed, so this class of leak is invisible if you only watch the JS heap.

server/api/proxy.get.ts
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.

Gotcha· `swr` and ISR on a long-running node server store HTML in memory

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.

Exercise

Exercise
  • Reproduce leak #1: a universal plugin with a module-scope array pushing 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.
  • Reproduce leak #2: register nitroApp.hooks.hook('afterResponse', …) inside an event handler. Hit it twelve times and find MaxListenersExceededWarning in the terminal.
  • Reproduce leak #4: defineCachedEventHandler without getKey, load-tested with ?t=${Math.random()}. Then add the normalising getKey and re-run the same load; the heap should flatten.
  • Grep your own layer for new Map(, setInterval(, process.on(, .hooks.hook( and let under server/ and app/plugins/. Justify every hit or fix it.

Be able to say

Be able to say· Name three Nuxt-specific server leak patterns and what you'd do about each.

"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."