Our last article ended with a teaser: “the new site is coming soon, but we’re not giving away the date”. The date was Monday 23 February 2026. The first release tag went out at 00:45, and by the end of the day we had cut ten, and fourteen over the week.

Migration stories usually stop at the cutover, as if the work ended there. In reality, it is the first day your code meets real traffic, the kind no preproduction environment reproduces faithfully. For us, that meeting looked like a memory graph that climbed, kept climbing, then dropped off a cliff every time Kubernetes killed the pod.

Here is what happened that week, what we fixed, what we had to undo, and the resilience architecture we ended up building over the following months.

Nine storefronts, one cutover

The Zadig&Voltaire site is nine storefronts: France, the United Kingdom, Germany, Spain, Italy, Belgium, Switzerland, rest of world and the United States, with four currencies and five languages. The old Vue Storefront frontend served all of them. Nuxt had to take all of them over, on the same day.

We could have switched market by market. We chose not to: every storefront shares the same code, the same cluster and the same CDN, and running two frontends side by side for weeks would have doubled support, tracking and testing. The cutover happened at the CDN level, in one go.

The prerequisite was a release model we already trusted. We reused the one from our Magento backend: production only deploys from a Git tag named YYYY-WW-NN (year, ISO week, number within the week). The tag triggers the production image build, the deploy remains a deliberate click, and a k6 smoke test checks the key journeys right after.

The path of a change to production, left to right: merge request, merge to main, release tag, deploy, smoke test; below the pipeline, the number of tags per day during launch week Merge request sandbox + k6 Main merge preprod + k6 Release tag YYYY-WW-NN Deploy build + 1 click Prod smoke k6, read-only Launch week: release tags per day 10 Mon 23 Feb 1 Tue 24 Feb 2 Wed 25 Feb 0 Thu 26 Feb 1 Fri 27 Feb
One release tag per production deploy. On launch Monday, we cut ten.

That model has a virtue you mostly appreciate on launch day: every fix is an identifiable release you can review, replay or roll back. Ten releases in a day is the short feedback loop doing its job.

Monday: the climbing memory

The first hours went fine. Then the memory graphs of the SSR pods started drawing a sawtooth: the heap climbed to about 1 GB, the pod got killed for running out of memory, another one took over and did the same. Visitors saw a few errors while pods restarted in a loop.

We first bought the system some room, because nobody debugs calmly while the site is falling over. During the day, the Node heap went from 1.5 to 2 GB, pod memory from 2 to 3 GB, and autoscaling from 6–15 pods to 10–30. That is not a fix, it is a tourniquet. We had to find the leak.

The same day, the first two culprits fell:

  • Adyen payment state lived at module level. Three let variables at the top of the composable, designed as a browser-side singleton. On the server, a module is loaded once for every request: those variables kept a reference to the last request’s instance, and through it to its entire context. We moved them onto nuxtApp, which is per-request on the server and persistent in the browser.
  • Several unbounded caches. Apollo responses piled up in the in-memory cache on every render, CMS responses (more than 600 KB per language) were not cached on the server, and two rate limiters grew without limit under bot traffic. A no-cache policy for Apollo during SSR, a cached handler for the CMS, maps capped at 10,000 entries with a regular sweep.

The curve calmed down. It did not flatten.

Tuesday: the leak that only hit logged-in customers

To go further, we had to reproduce. We had a k6 suite from launch preparation; that week we turned it into a real investigation tool:

  • 100 distinct test accounts, created automatically if missing, with populated wishlists;
  • three locales in parallel (France, Italy, Spain), each with its own thresholds;
  • varied journeys: browsing, content pages, add to cart, checkout without payment, account pages;
  • a bot scenario requesting broken URLs, and cache busting to force SSR rendering.

The result was immediate and surprising: anonymous visitors did not leak. Only authenticated requests made the heap grow.

The cause fit in one line. The plugin that injects the customer token into Apollo read the cookie inside the apollo:auth hook, which fires for every GraphQL query. And useCookie is not a simple read: it creates a Ref and registers two hooks that capture nuxtApp. With five to ten queries per page, every authenticated render left dozens of reactive objects hanging off the request context.

export default defineNuxtPlugin((nuxtApp) => {
  // Read once per request, in the plugin's setup context
  const apolloToken = useCookie<string | null>('apollo.token')

  nuxtApp.hook('apollo:auth', ({ token }) => {
    token.value = apolloToken.value || null
  })
})

Before the fix, the useCookie line sat inside the hook. No code review catches this kind of bug, because the code is functionally correct. Only an authenticated load test brings it to light.

Wednesday: understanding why Nuxt frees nothing

What remained was slow growth across all traffic types. A heap snapshot taken on a pod after 743 requests weighed 785 MB: about 1.16 MB retained per request, dominated by Vue reactivity objects.

The explanation is structural. During SSR, Nuxt creates a full Vue application per request, renders it to HTML, then leaves it to the garbage collector. That works as long as nothing outside the request points to it. But several libraries keep a module-level reference, designed for the browser where there is only ever one application:

Four module-level variables keep the objects of a finished SSR request alive; the cleanup plugin cuts these references at the end of every render Request N, supposed to vanish Module-level variables, never released The request's nuxtApp Vue app, router, context Pinia and its stores cart, user, wishlist Apollo client cache, queries in flight Nuxt UI overlays reactive objects per render Effect scopes, vue-i18n watchers, computed Adyen payment state let checkoutInstance retains Active Pinia activePinia retains Provided Apollo clients currentApolloClients retains Shared composable useOverlay() piles up ssr-cleanup plugin, on app:rendered and app:error stop scopes, disposePinia, reset Apollo, clear singletons, unmount
A finished SSR request stays in memory as long as a module-level variable points to it.
  • activePinia holds the last Pinia instance, hence its stores, hence the Vue app, hence the router.
  • currentApolloClients holds the last Apollo client and its normalised cache.
  • Nuxt UI’s useOverlay is a shared composable: its overlays array lives at module level and gets a reactive entry on every render, never emptied, since components are never unmounted during SSR.
  • The effect scopes of Nuxt, Pinia and vue-i18n are never stopped.

Our answer was a server plugin that runs last and cleanly tears down everything the request created: stop the scopes, disposePinia, reset the Apollo clients, clear the singletons, then vueApp.unmount() to trigger the cleanups that libraries wire to unmount.

One detail cost us a day: the cleanup must also run on app:error. When rendering fails, Nuxt fires app:error but never app:rendered. Without that second hook, every page that errored, and bots generate plenty of those, leaked its entire context.

The following week: undoing what went too far

In the heat of the moment, we also pushed more aggressive optimisations. The most tempting one: the storefront configuration (385 KB) lived in app.config, which Nuxt deep-clones on every access during rendering. We had replaced it with a frozen object shared across requests, and neutralised an unused Apollo module by aliasing it to an empty one.

On paper, that meant fewer allocations. In practice, those workarounds introduced subtle regressions, because they stepped outside the lifecycle the framework expects. On 9 March, we reverted all of it, then reintroduced only what held up.

What stayed are the fixes that follow the framework: the cookie read in the right place, state moved onto nuxtApp, client-only components kept on the client, bounded caches, and the cleanup plugin hooked into official lifecycle events. What went are the shortcuts that worked around it.

July: when memory is no longer a leak but a wave

With the leaks fixed, pod memory became stable under normal load. Spikes remained. On 3 July, a sudden traffic spike saturated SSR rendering. One pod ran out of memory, and Kubernetes killed the other five in under twenty seconds. For two minutes, real customers got errors.

The diagnosis was awkward because the cause was ours: the liveness probe failed as soon as the heap crossed a threshold. Under load that hits every pod at once, that amounts to asking Kubernetes to restart the whole fleet in one go, at the exact moment you need it most.

We put the lines of defence back in the right order: shed load first, take a pod out of rotation next, restart it only as a last resort.

Three tiers, left to right as pressure on a pod rises: shed first, leave the load balancer next, restart only after several minutes above a high threshold Normal pages rendered in SSR 1. Shed 503 + Retry-After 2. Leave the pool readiness /healthz 3. Restart liveness /livez Rising pressure on the pod, then pressure that lasts
Shedding kicks in past a heap threshold, a number of concurrent renders or an event loop delay.

Liveness became blind to transient pressure. The new /livez route returns 200 as long as the process is alive. One exception: a heap stuck above a high threshold for several consecutive minutes, the sign of a real leak the garbage collector will never recover. Measured spikes last one to two minutes: a synchronised fleet restart becomes impossible.

Readiness, on the other hand, stays sensitive. Above a heap threshold, the pod leaves the load balancer while the GC does its job, then comes back. Nobody gets killed.

And before all that, a load-shedding middleware. Past a heap threshold, a number of concurrent renders or an event loop delay, new page requests immediately get a 503 with a Retry-After. API routes (cart, payment, account) and health probes are never shed: only HTML rendering is, and the browser knows how to retry.

if (heapPressure || eventLoopSaturated || inFlightRenders >= MAX_CONCURRENT_RENDERS) {
  // Raw response: createError would SSR-render error.vue, a full render per shed request
  const res = event.node.res
  res.statusCode = 503
  res.setHeader('content-type', 'text/plain')
  res.setHeader('retry-after', '5')
  res.setHeader('cache-control', 'no-store')
  res.end('Server overloaded')
  return
}

The comment in that block sums up a trap we narrowly avoided: returning a regular Nuxt error would have triggered a render of the error page, about 240 KB of work per shed request. Load shedding that renders sheds nothing.

The event-loop trigger arrived in August, after a series of short bursts where the concurrent-render cap was never reached while overly nervous probes were already draining the pool. We measured what was actually saturating (event loop delay, via monitorEventLoopDelay) and relaxed the probes so they only act after sustained saturation. In the validation test on preproduction, under a load heavier than the real incident, the pool never dropped below two pods and none restarted.

Meanwhile, the fleet itself changed shape: three on-demand pods as a fixed baseline, plus 4 to 28 pods on Spot instances driven by autoscaling. A stable base for availability, elasticity at the best price for spikes.

What we learned

  • Launch day is the project’s first real test. Plan the team, the observability tools and a release model that lets you ship ten fixes in a day.
  • Load test with logged-in users. Our most severe leak only affected authenticated requests. An anonymous-only k6 suite would never have shown it.
  • In SSR, a module-level variable is a variable shared by all your customers. Anything that looks like a browser singleton deserves a second read with that in mind.
  • Follow the framework, even when optimising. Our lasting fixes go through the hooks and lifecycle the framework provides. The clever workarounds were reverted two weeks later.
  • A liveness probe must never react to a recoverable state. Otherwise, global overload turns into a global restart.
  • Shed early, with a response that costs nothing. A raw 503 with Retry-After beats a rendered error page, and beats a falling pod by far.

The rest of the story is the features we could ship on top of this foundation, starting with the mobile checkout and Core Web Vitals.