Memory leaks

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.

One fact anchors this whole section: Nuxt plugins and component setup() run on every SSR request. A browser runs your plugins once per page load and then throws the whole JavaScript context away when the tab closes. Node does not. The server process stays up for days, and a fresh Vue app, a fresh nuxtApp, a fresh run of every universal plugin and every component's setup happens for every single request that arrives. Anything those runs create at module scope, or attach to something global, is still there when the next request arrives — and the one after that, several thousand times an hour.

What a leak actually is

A leak is not "memory that is used". It is a longer-lived owner holding a reference to a shorter-lived object, so the garbage collector cannot reclaim it. V8 frees anything unreachable from the roots; if a process-lifetime Map, a listener array on process, a timer's callback closure or a module-scope let still points at a per-request object, that object is reachable and stays. The variant that hurts just as much is the structure that is supposed to be long-lived but only ever grows: a cache with no bound, a list of "recently seen" ids, a registry that is written to and never read.

That reframes the hunt. You are not looking for big objects, you are looking for owners with the wrong lifetime. Nearly every fix in this section is the same move: give the value a home that expires when it should — event.context for a request, useState or nuxtApp for an app instance, an effectScope you can stop, a cache with a key and a TTL.

The security cousin

The same mistake from a different angle is cross-request state pollution. A module-scope let currentUser, a Pinia instance created at import time, a const cache = new Map() keyed by nothing: request one writes into it and request two reads it back. That is not memory growth, it is one user's data rendered into another user's HTML — the same shape as Nuxt's own advisories about personalised responses cached without varying on identity. On the server, "does this leak?" and "can this leak data?" are one question, which is why the bug gets fixed on Friday afternoon rather than next quarter.

Why you own this as a layer or module author

An application team that leaks memory restarts its own pods. A layer or module author who ships a setInterval in a universal plugin, or a hand-rolled Map cache in a server util, ships that leak into every consuming app, where it surfaces as OOM restarts in someone else's Grafana with no obvious link to your package. Consumers cannot fix it without forking you, and they usually cannot even find it — the retainer chain ends in a file they have never opened. Your runtime code therefore has a stricter rule than theirs: no module-scope mutable state, every global side effect client-only or scoped, every cache bounded and documented.

Gotcha· Your playground will never show you this bug

Server leaks need sustained traffic to become visible, and a layer's dev playground gets a few dozen reloads from one developer. Worse, nuxt dev restarts the server whenever you save, which quietly resets the heap and hides exactly the accumulation you are looking for. Leaks in shipped layers are therefore found by consumers in production, weeks later. The only way to see one before they do is to build the fixture, run it with node .output/server/index.mjs, and put a load generator in front of it — which is why this section ends in a CI test rather than a habit.

Exercise

Exercise
  • In a scratch app, add app/plugins/leak.ts with const seen: unknown[] = [] at module scope and seen.push(nuxtApp) inside defineNuxtPlugin. Build, start, and hit the homepage 200 times with npx autocannon -c 10 -a 200 http://localhost:3000/.
  • Log process.memoryUsage().heapUsed from a server route before and after. Write down the number of megabytes per thousand requests — that figure is what you will quote in the interview.

Be able to say

Be able to say· Why do server-rendered Nuxt apps leak memory more easily than SPAs?

"Because the process outlives the request. In an SPA the browser tab owns everything and takes it all away on reload, so a sloppy module-scope array is at worst a session-long annoyance. In Nuxt, plugins and component setup run again for every SSR request inside one long-lived Node process, so anything created at module scope or attached to a global — an array, a Map, a process.on listener, a timer — accumulates request after request and never gets collected. A leak is always a longer-lived owner holding shorter-lived objects, and on the server that same mistake is also a security bug, because request-lifetime data stored at process lifetime is read back by the next user. As a layer author I care extra, because my plugin runs in every consuming app and they can't fix it without forking me."