Almost every memory question an interviewer asks is really "do you know which lifetime this value is in?". A Nuxt app has three, they are nested, and code that looks identical behaves completely differently depending on which one it runs in. Get this table into muscle memory and the leak catalogues on the next two pages stop being lists to memorise and become consequences you can derive.
server/ file, a Nitro plugin, or the server half of a universal plugin's module), Nitro plugins themselves, global caches, and listeners on process-lifetime emitters: process.on, timers, a database client, a message-bus connection. Owner: the Node process. Nothing here is ever collected while the server runs.event, event.context, a fresh nuxtApp for every SSR request (a fresh Vue app, a fresh set of plugin runs, a fresh component tree), the useState store and the payload that will be serialised into the HTML, the unhead instance, the per-request Pinia instance. Owner: the request. All of it should be unreachable the moment the response is flushed.nuxtApp, created once by the client entry and alive until reload or close; its payload/useAsyncData cache, its plugin state, its router. Components mount and unmount below it on every route change. Owner: the tab.setInterval in a universal plugin is a leak and in a .client.ts plugin is fine.const cache = new Map() in a file imported by both is two completely different objects with two completely different risk profiles.event.context or a local. This app instance → useState / nuxtApp.provide. The whole process, deliberately → a bounded cache with a key and a TTL, documented. There is no fourth answer.| Lifetime | What lives there | Owner | Ends when |
|---|---|---|---|
| Process (hours/days) | module-scope variables in the server bundle, Nitro plugins, global caches, process/timer/emitter listeners, DB pools | the Node process | the process restarts |
| Request (milliseconds) | event, event.context, a fresh nuxtApp per SSR request, useState/payload, unhead, Pinia | the request | the response is sent |
| Client session (minutes/hours) | the single nuxtApp, payload cache, plugin state; components mount/unmount per route | the browser tab | reload or close |
Two files that look similar and are not. The Nitro plugin body runs once; the event handler body runs per request:
// process lifetime: this body runs once, when Nitro starts
export default defineNitroPlugin((nitroApp) => {
let requests = 0
// registered once, for the life of the process — correct
nitroApp.hooks.hook('afterResponse', () => { requests++ })
// anything long-lived must also be tearable-down
nitroApp.hooks.hookOnce('close', () => { console.log('served', requests) })
})
// request lifetime: this body runs for every request
export default defineEventHandler(async (event) => {
// correct home for per-request data: dies with the event
event.context.user = await resolveUser(event)
return { name: event.context.user.name }
})
The universal plugin is the one to reason about out loud, because it is both lifetimes at once:
// ⚠ module scope. On the server this is ONE array for the whole process.
const buffer: unknown[] = []
export default defineNuxtPlugin((nuxtApp) => {
// this callback runs once per tab in the browser, and once per SSR request in Node
buffer.push(nuxtApp) // leaks every request's Vue app, forever
if (import.meta.client) {
// safe: the tab owns this, and it goes away with the tab
setInterval(() => flush(), 10_000)
}
})
The same intent, written so each value sits in the lifetime that should own it:
import { effectScope, shallowRef } from 'vue'
export default defineNuxtPlugin(() => {
// client session: one buffer per tab, never one per request
const buffer = shallowRef<unknown[]>([])
// detached scope: no component owns this, so I own it and I can stop it
const scope = effectScope(true)
scope.run(() => {
// VueUse registers its own cleanup on the active scope, so stopping it clears the timer
useIntervalFn(() => flush(buffer.value), 10_000)
})
// one teardown for everything the plugin started (HMR would otherwise stack timers in dev)
if (import.meta.hot) import.meta.hot.dispose(() => scope.stop())
return { provide: { telemetry: { buffer } } }
})
People read "shared, SSR-friendly state" and conclude useState is a singleton. It is a key into nuxtApp.payload.state, and there is one nuxtApp per SSR request. That is precisely why it is safe on the server and why a module-scope ref() is not: the ref is created once per process and shared by every user, the useState is created once per request. The corollary bites in the other direction on the client, where there is one nuxtApp for the whole tab — a useState array that a route pushes into on every visit grows for the life of the session.
The .server suffix means "does not ship to the browser", not "runs once". A .server.ts plugin body executes for every SSR request, so registering a process.on('unhandledRejection', …) there adds one listener per request and you will see MaxListenersExceededWarning within minutes of real traffic. Process-lifetime setup belongs in server/plugins/* (defineNitroPlugin), which genuinely runs once.
console.log('plugin ran') to a universal plugin, a .client.ts plugin and a Nitro plugin. Build, start the server, and reload a page five times. Count the lines from each in the terminal and in the browser console — the numbers are the three lifetimes.let runs = 0, increment it in the plugin body, and expose it from a server route. Reload ten times and watch it climb; note that the browser's copy stays at 1."There are three. The process lives for hours or days and owns module-scope variables in the server bundle, Nitro plugins, global caches and anything registered on process or a long-lived client. The request lives for milliseconds and owns the h3 event, event.context, and a completely fresh nuxtApp — Nuxt builds a new Vue app and re-runs every universal plugin and every setup() for each SSR request. The client session is a browser tab: one nuxtApp for its whole life, with components mounting and unmounting underneath it. The trap is that a universal plugin runs in two of those lifetimes with the same source code. So the question I ask about any value is who should still hold it after the response is sent: nobody means event.context or a local, this app instance means useState or nuxtApp.provide, and the whole process only when I meant it, in which case it gets a bounded key and a TTL."
Memory leaks
Why a Nuxt server leaks where an SPA does not, what a leak actually is, why cross-request state pollution is its security cousin, and why a layer or module author owns the risk for every consuming app.
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.