Memory leaks

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.

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.

Know

  • Process lifetime — the Node server, hours to days. Module-scope variables in the server bundle (everything at the top level of a 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.
  • Request lifetime — milliseconds. The h3 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.
  • Client session — a browser tab, minutes to hours. Exactly one 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.
  • Universal plugins live in two lifetimes at once. The same file runs once per tab in the browser and once per request on the server. Every line has to be correct in both readings — this is why setInterval in a universal plugin is a leak and in a .client.ts plugin is fine.
  • Module scope is not a lifetime, it is a trap door into the longest one. Top-level code in a server bundle runs once per process; top-level code in a client bundle runs once per tab. A const cache = new Map() in a file imported by both is two completely different objects with two completely different risk profiles.
  • The deciding question: "who should still hold this after the response is sent?" Nobody → 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.

The table

LifetimeWhat lives thereOwnerEnds when
Process (hours/days)module-scope variables in the server bundle, Nitro plugins, global caches, process/timer/emitter listeners, DB poolsthe Node processthe process restarts
Request (milliseconds)event, event.context, a fresh nuxtApp per SSR request, useState/payload, unhead, Piniathe requestthe response is sent
Client session (minutes/hours)the single nuxtApp, payload cache, plugin state; components mount/unmount per routethe browser tabreload or close

How it works

Two files that look similar and are not. The Nitro plugin body runs once; the event handler body runs per request:

server/plugins/metrics.ts
// 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) })
})
server/api/profile.get.ts
// 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:

app/plugins/telemetry.ts
// ⚠ 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:

app/plugins/telemetry.client.ts
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 } } }
})
Gotcha· `useState` is not global state, it is per-instance state

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.

Gotcha· A `.server.ts` plugin is still per request

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.

Exercise

Exercise
  • Add 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.
  • In the universal plugin, add a module-scope 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.
  • Take one value from a real layer you maintain and write down which lifetime owns it and where it is stored today. If the answer is "module scope in runtime code", you have found a bug.

Be able to say

Be able to say· Walk me through the lifetimes in a server-rendered Nuxt app and how you decide where a value belongs.

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