Cross-Request State Pollution in SSR Singletons

A server-rendered app occasionally shows one user’s name in another user’s page, and the rendering server’s heap grows with every request until it restarts. Both symptoms come from the same mistake: state containers that are singletons in the browser — a Redux or Pinia store, a TanStack Query or Apollo client, a data-loader cache — were created at module level on the server, where one process serves everyone. This guide from SSR Heap Exhaustion and Per-Request Memory, in Node.js Server-Side Memory Management, shows how to find module-level state in SSR code and replace it with per-request instances.

Symptom Root Cause Immediate Action Measurable Impact
User A’s data appears in user B’s page Module-level store or client shared across requests Create a new store/client per request Correct isolation
Heap grows with each rendered page Shared query/entity caches accumulate every request’s data Per-request caches; bound any shared cache Memory proportional to concurrency
Subscribers pile up on a shared store Each render subscribes; nothing unsubscribes on the server Per-request store; no subscriptions during SSR No accumulated listeners
Hydration mismatches under load Concurrent requests mutate the same singleton Request-scoped instances Deterministic output
Growth only under concurrency Race between overlapping requests on shared state Instance per request via factory No shared mutable state

Root Cause: A Browser Singleton Is a Server-Wide Global

In the browser, one tab serves one user, so a module-level export const store = createStore() is effectively per user and dies with the tab. On the server, the same module is loaded once per process and shared by every request the process handles, concurrently and for its whole lifetime. Any mutable state at module level therefore becomes shared between users and retained for the life of the process — the server-side face of module-level caches and global singleton leaks.

The common offenders are state containers designed for the client: a Redux, Zustand or Pinia store created at import time; a TanStack Query QueryClient or Apollo ApolloClient instantiated in a shared module, whose caches then collect every request’s data (see TanStack Query cache garbage collection settings for the client side of that cache); a Vue app created once with createSSRApp outside the request handler; an i18n instance whose loaded messages or current locale are mutated per request; and hand-written caches such as const users = new Map() used during rendering.

Two failure modes follow. Correctness: a request that writes the current user into a shared store can be read by another request rendering at the same time, leaking personal data between users — a security incident, not just a bug. Memory: caches in shared clients accumulate entries for every distinct query or entity rendered, and stores gain subscribers and derived data per render; nothing clears them because nothing on the server corresponds to “closing the tab”. The heap grows with traffic until heap exhaustion.

The rule for SSR is simple: anything mutable that relates to a request is created inside the request — through a factory called by the request handler, passed down explicitly or through a request context such as AsyncLocalStorage. Only immutable data and deliberately shared, bounded caches of non-user data may live at module level.

Shared singleton versus per-request instances Left: three concurrent requests for users A, B and C all read and write one module-level store and query client; data from all users accumulates and can leak into other responses. Right: the request handler calls createStore and createQueryClient for each request, so each request has its own instances, which become garbage after the response is sent. request A request B request C module-level store users A+B+C mixed, grows forever request A request B request C store A (released) store B (released) store C (released) shared: leaks data, grows per request: isolated, bounded

Step-by-Step Fix

  1. List module-level instances in server-executed code. Search SSR entry points and shared modules for createStore, new QueryClient, new ApolloClient, createPinia, createSSRApp, createI18n and top-level new Map()/[] that are written during rendering. Verification: you have a list of candidate singletons.
  2. Classify each. Immutable configuration and bounded, user-independent caches may stay at module level; anything that holds request or user data must become per-request. Verification: each candidate has a decision.
  3. Introduce factories. Replace export const store = createStore() with export function makeStore(initial), and call it inside the request handler (and once in the browser). Verification: each request constructs its own store and clients.
  4. Pass instances down, not up. Provide per-request instances through the framework’s provider/context mechanism or a request context; remove imports of singletons from components. Verification: components no longer import a store instance directly.
  5. Bound anything that stays shared. Put size limits and TTLs on module-level caches of public, non-user data. Verification: shared cache sizes plateau under load.
  6. Test concurrency and memory. Fire concurrent requests for different users and check responses for cross-contamination; run a soak test and compare heap after GC. Verification: no data mixing and a flat heap trend.
Heap over 100,000 server renders With a module-level query client, cached query results for every rendered page accumulate and the heap grows from 150 megabytes to about 1.3 gigabytes over one hundred thousand renders. With a query client created per request, the heap stays around 170 megabytes. 1.3 GB 0 module-level QueryClient QueryClient per request renders (0 → 100,000)

Command and Code Reference

Use case: per-request query client and store in a React SSR handler.

// server/render.jsx
import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query';
import { makeStore } from '../store.js';                 // factory, not a singleton

export async function render(req, res) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { staleTime: Infinity, retry: false } },
  });                                                    // new cache for this request only
  const store = makeStore({ user: req.user });           // new store for this request only

  await queryClient.prefetchQuery({ queryKey: ['products'], queryFn: loadProducts });
  const html = renderToString(
    <QueryClientProvider client={queryClient}>
      <App store={store} />
    </QueryClientProvider>,
  );
  const state = JSON.stringify(dehydrate(queryClient)).replace(/</g, '\\u003c');
  res.send(page(html, state));
  queryClient.clear();                                   // drop cached data promptly
}

Use case: a Vue SSR entry that creates app, router and Pinia per request.

// entry-server.js
import { createSSRApp } from 'vue';
import { createPinia } from 'pinia';
import { createRouter } from './router.js';
import App from './App.vue';

export function createApp() {            // called once per request on the server
  const app = createSSRApp(App);
  const pinia = createPinia();
  const router = createRouter();         // memory history on the server
  app.use(pinia).use(router);
  return { app, pinia, router };
}

Verification and Regression Prevention

Verify isolation with a concurrency test: render pages for different users in parallel many times and assert that each response contains only its own user’s data. Verify memory with a soak test: after thousands of renders and a forced GC, heap should return close to its baseline and snapshots should contain no stores or clients beyond those of in-flight requests.

Prevent regressions with a lint rule that forbids importing store or client instances in modules that run on the server (allow only factories), and with the concurrency test in CI. Framework documentation for SSR almost always recommends per-request creation; make that a code-review item for every new state library. Once instances are per request, keeping their memory small is covered in fixing memory leaks in Next.js server rendering.

Isolation and soak tests Render pages for different users in parallel many times and assert each response contains only its own user’s data. Then run thousands of renders, force GC, and confirm heap returns close to baseline with no stores or clients beyond those in flight. Parallel renders many users at once Assert isolation only own user data Soak + GC thousands of renders Heap at baseline no extra stores/clients

Edge Cases and Gotchas

Framework-managed singletons

Some frameworks create stores or clients for you per request (for example through special data-loading functions). Check that your own code does not add a second, module-level instance alongside them.

Shared caches of public data

A cache of public, user-independent data (product catalogue, translations) can legitimately be shared across requests — but it must be bounded and must never contain per-user fields. Keep it separate from per-request state.

Mutating imported configuration

Objects imported from configuration modules are shared too. Mutating them during a request (for example setting config.locale = req.locale) creates the same cross-request bug; derive per-request values instead.

Serverless and edge runtimes

Warm serverless instances and edge isolates reuse module state across invocations in the same way. Per-request creation is equally necessary there.

Frequently Asked Questions

Why is a global store dangerous in server-side rendering?

On the server, one process handles many users, so a module-level store is shared by all of them. Data written by one request can appear in another user’s response, and caches in the store grow with every request, causing memory growth.

Should I create a new Redux or Pinia store for every request?

Yes, for state that relates to the request or user. Create the store inside the request handler with a factory, render with it, serialise its state for hydration, and let it be garbage collected after the response.

Is creating a new QueryClient per request expensive?

Creating the client is cheap compared with rendering and data fetching. The benefit — isolated caches that are released after the response — far outweighs the cost. Shared caching of public data, if needed, belongs in a separate, bounded layer.

How do I find module-level state in a large codebase?

Search for instantiation of known state libraries and for top-level mutable collections in modules imported by server entry points, then check whether they are written during rendering. Heap snapshots after load tests also reveal them as large retained structures under module contexts.

Can AsyncLocalStorage replace passing instances down?

It can provide request-scoped access where passing props or context is awkward, such as in logging or data loaders. The instances still need to be created per request; the storage only makes them reachable without globals.

What about caches that need to survive between requests?

Keep them for user-independent data, bound them by size or time, and document why they are shared. Anything that depends on the user, session or request must be per request.