The previous two pages are catalogues of mistakes. This one is the short list of habits that makes most of those mistakes impossible to write, framed for the job you are interviewing for: you are not fixing leaks in one app, you are shipping runtime code into every app that installs your layer or module. A consumer cannot patch your plugin, usually cannot find it in a snapshot, and will report the symptom as "the framework is leaking". So the bar for your code is higher than for theirs, and these are the six rules that raise it.
event.context; in universal code it is useState, nuxtApp.provide or a value returned from the plugin. Runtime code in a layer or module contains no module-scope mutable state — no let, no Map, no ref() at the top level of a file that ends up in the server bundle. A frozen config object is fine; anything you write to is not..client, or lazy, or scoped. Timers, window/document access, heavy SDK initialisation, connections: put it in a .client.ts plugin so it never enters the server bundle, or create it lazily on first use and wrap it in an effectScope() you keep a handle to and can stop(). "Scoped" is the difference between a side effect and a leak.max plus a ttl (or maxAge plus staleMaxAge); a deliberate key is one you wrote yourself and whose value space you can describe in a sentence. Prefer Nitro's cache layer or an unstorage driver over a hand-rolled Map, so production can point it at Redis and a restart does not lose the world. Then write it down in the README — memory behaviour is part of your public API.onScopeDispose (or VueUse's tryOnScopeDispose, which is a no-op outside a scope) rather than onUnmounted, and return the stop function as well, so a caller outside a component can still do the right thing.nuxtApp.hook(), nitroApp.hooks.hook(), router.afterEach() and hookable in general return removers. A layer's event-bus or hook wrapper must return that function too, or you have designed a leak into your API.close handler. Child processes, watchers, sockets and dev servers started in setup() outlive nuxt dev restarts otherwise. nuxt.hook('close', …) is the teardown, and it costs three lines.A composable that is correct in every context a consumer can call it from — component, plugin scope, detached scope, and SSR:
import { getCurrentScope, onScopeDispose, shallowRef } from 'vue'
export function useLivePrice(symbol: string) {
const price = shallowRef<number | null>(null)
// SSR: return the same shape, create nothing. No socket, no timer, no leak.
if (import.meta.server) return { price, stop: () => {} }
const socket = new WebSocket(`wss://prices.internal/${symbol}`)
const controller = new AbortController()
socket.addEventListener('message', e => { price.value = JSON.parse(e.data).price }, { signal: controller.signal })
const stop = () => { controller.abort(); socket.close() }
// adopted by a component's scope, a plugin's effectScope, or anything else that owns one
if (getCurrentScope()) onScopeDispose(stop)
else if (import.meta.dev) console.warn('[team-layer] useLivePrice() called outside an effect scope — call stop() yourself')
// returning `stop` is not optional: it is the escape hatch for callers with no scope
return { price, stop }
}
A cache with a bound, a key you can describe, and no request objects captured:
// Nitro's cache layer: swap the storage driver for Redis in production, no code change
export const getRates = defineCachedFunction(
async (base: string) => $fetch<Rates>(`https://api.internal/rates/${base}`),
{
name: 'rates',
group: 'team-layer',
maxAge: 60 * 15,
staleMaxAge: 60 * 60,
// primitives only — never close over `event`, `nuxtApp` or anything request-shaped
getKey: (base: string) => base.toUpperCase().slice(0, 3),
},
)
A module that spawns something in dev and cleans up after itself:
import { spawn } from 'node:child_process'
import { addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit'
export default defineNuxtModule({
meta: { name: '@team/nuxt-preview', configKey: 'preview' },
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
addServerPlugin(resolver.resolve('./runtime/server/plugin'))
if (!nuxt.options.dev) return
const child = spawn('node', [resolver.resolve('./bin/preview.mjs')], { stdio: 'inherit' })
// without this, every dev restart orphans one more process and its file watchers
nuxt.hook('close', () => { child.kill() })
// the watcher callback does work only — it never registers more hooks
nuxt.hook('builder:watch', (_event, path) => {
if (path.endsWith('.preview.json')) regeneratePreview(path)
})
},
})
And the paragraph in the README that turns all of this into something a consumer can reason about:
## Memory behaviour
| What | Lifetime | Bound |
| --- | --- | --- |
| `getRates()` | Nitro `cache` storage (in-memory by default) | 15 min `maxAge`, key = 3-letter currency code |
| `useLivePrice()` | the caller's effect scope | socket closed on scope dispose; `stop()` exported |
| `preview` child process | `nuxt dev` only | killed on the `close` hook |
No module-scope mutable state ships in this layer's runtime code. Point
`nitro.storage.cache` at Redis in production if you run more than one instance.
onUnmounted only fires if a component instance mounted. A composable called from a plugin, from a detached effectScope, from a store, or from a component that errors during setup gets no teardown at all — and in a layer you do not control where consumers call it from. onScopeDispose fires whenever the owning scope stops, which covers all of those, and tryOnScopeDispose degrades to a silent no-op when there is no scope instead of warning in the console. Use onUnmounted only for things that genuinely need a mounted DOM.
nuxt.hook('builder:watch', () => { nuxt.hook('build:done', …) }) looks like lazy registration and is actually a multiplier: every file change adds another build:done listener, so after an hour of nuxt dev your module runs the same work forty times and the dev server crawls. Register at setup time, or use hookOnce when the registration genuinely has to happen from inside a callback.
Run this over every PR that touches runtime code in the layer. It takes two minutes and catches almost everything on the previous two pages.
let, Map, Set, array or ref() in anything under runtime/, server/ or app/plugins/.setInterval, setTimeout loop, addEventListener, observer or connection has a matching teardown in the same file — and the file is .client.ts if it touches the browser.watch / watchEffect / computed outside a component sits inside an effectScope() that something stops.name, an explicit key function, a maxAge, and a line in the README.hook() / on() call either lives in a plugin (once per lifetime) or its remover is captured and called.event, nuxtApp, a Vue app instance) is captured by a closure that outlives the request.setup() that spawns a process, watcher or server has a nuxt.hook('close', …).useLivePrice shape: SSR early return, getCurrentScope() guard, onScopeDispose, exported stop.Map cache with defineCachedFunction, give it a getKey you can describe in one sentence, and add the README table row..github/pull_request_template.md and run it against the last three merged PRs. Count the hits.nuxt.hook('close', …) to any module that spawns something, then run nuxt dev, edit a file ten times, and confirm with ps that only one child process exists."Six rules, and they all come from the fact that my plugins run on every SSR request in an app I don't operate. Request-lifetime data goes in event.context on the server or useState and nuxtApp in universal code, and there is no module-scope mutable state in runtime code at all — that is a grep I run in CI. Anything with a global side effect is either a .client.ts plugin so it never reaches the server bundle, or it is lazy and wrapped in an effectScope I hold a handle to and can stop. Every cache is bounded with a deliberate key and a TTL, and I use Nitro's cache layer or unstorage rather than a Map so production can point it at Redis. Composables register teardown with onScopeDispose rather than onUnmounted, because consumers call them from plugins and stores too, and they also return a stop function. Anything I subscribe to hands back an unsubscribe and my API passes it on. And module setup that spawns a watcher or a child process registers a close handler. Then the README documents the memory behaviour, because for a layer that is public API, and a PR checklist plus the memory watermark test keeps it honest."
Detecting and measuring
How to prove a leak instead of guessing — health-route baselines, the Chrome DevTools snapshot workflow, production-safe Node flags, the navigate-20-times client test, and a memory watermark test for CI.
Performance
Where the time actually goes in a Nuxt app, the senior answer shape, and why a layer author's first performance question is what the layer costs every consuming app.