skip to content

In a Vue 3 SSR app, a store created with reactive() at module scope shows one visitor's data to another visitor. Why, and what is Vue's recommended fix?

level: seniorimportance: must knowfreq 55%

answer

  1. modules evaluated once per process
  2. singleton shared by requests
  3. concurrent renders interleave
  4. factory plus app-level provide

basics

~20 s

On the server a module is evaluated once per process, so a module-level reactive() object is shared by every request. Vue's fix: create a new app per request, create the store inside that factory, and share it with app.provide.

solid answer

~50 s

In the browser, modules are initialised fresh for each page visit, so a module-level `reactive()` store is effectively per user. On the server the same module is evaluated once when the process loads it and reused by every request, so one visitor's data written into it is visible to the next render. Vue's SSR guide calls this **cross-request state pollution**. Resetting the store at the start of each request is not safe, because renders await data and two requests can interleave on the same object. The recommended fix is an app factory: a function that calls `createSSRApp`, creates the stores and the router inside it, registers them with `app.provide`, and returns them; the server calls it for every request, components `inject` instead of importing the singleton, and the returned store is what the server serializes for hydration.

code

ts · 15 lines
ts
// UNSAFE under SSR, evaluated once per server process:
//   export const cart = reactive({ items: [] as string[] })

// app.ts: a fresh app and store for every request
import { createSSRApp, reactive, type InjectionKey } from 'vue'
import App from './App.vue'

export const cartKey: InjectionKey<{ items: string[] }> = Symbol('cart')

export function createApp() {
  const app = createSSRApp(App)
  const cart = reactive({ items: [] as string[] })
  app.provide(cartKey, cart)
  return { app, cart } // server: call per request; client: call once
}

go deeper

for a junior

Remember that on the server a module-level object is shared by every visitor, so per-user state must be created per request.

for a middle

Explain the factory pattern: createSSRApp plus stores inside a function, app.provide to share them, inject to read them, and the server calling it for every request.

for a senior

Hunt for hidden singletons, including composables, routers and third-party libraries, and explain why resetting state between requests fails under concurrent async renders.

for a principal

Set the architectural rule that per-user state is created per request and decide how the team enforces it, from review checklists to concurrent-user tests in CI.

## Module scope is process scope on the server A common Vue pattern is **shared state in a module**: `export const cart = reactive({ items: [] })`, imported by any component that needs it. In a client-only app this is safe, because the browser evaluates your modules fresh for every page visit, so each visitor gets their own object. On the server, the module system evaluates each module **once** per process and caches it. Every request that imports `store.ts` receives the same object. The object is a **singleton** for the lifetime of the server, and the Vue SSR guide names the resulting bug **cross-request state pollution**: data specific to one user, mutated into shared state, leaks into a response for another user. ## How it shows up - One visitor's name, cart or permissions appear in another visitor's HTML. - Lists grow across requests because each render appends to the same array. - Memory climbs steadily because per-user data is never released. - It is intermittent: it depends on traffic and timing, so a single developer clicking through locally rarely sees it. ## Why resetting the singleton is not a fix Renders are asynchronous: `onServerPrefetch` callbacks await network calls, and while one request waits, the server handles another. A typical interleaving: 1. Request A sets `store.user = 'A'` and awaits a fetch. 2. Request B arrives, resets the store and sets `store.user = 'B'`. 3. Request A resumes and renders its page with user B. The only reliable fix is that no two requests ever hold the same object. ## The recommended fix: an app per request The Vue SSR guide recommends creating a new instance of the entire application, including the router and global stores, on each request, and sharing state through **app-level provide** rather than direct imports: 1. Put app creation in a universal factory function, for example `createApp()` in `app.ts`. 2. Inside it, call `createSSRApp`, create each store, and register it with `app.provide(key, store)`. 3. Components read it with `inject(key)` instead of importing a module-level object. 4. The server handler calls the factory for every request; the client calls it once on page load. 5. The factory returns the store too, so the server can serialize its state for hydration. ```ts import { createSSRApp, reactive, type InjectionKey } from 'vue' import App from './App.vue' export interface Cart { items: string[] } export const cartKey: InjectionKey<Cart> = Symbol('cart') export function createApp() { const app = createSSRApp(App) const cart = reactive<Cart>({ items: [] }) // one per request app.provide(cartKey, cart) return { app, cart } } ``` Re-evaluating every module per request would also isolate requests, but the guide rejects it: initialising modules is costly and would hurt server performance. ## Where state can live | Location | In the browser | On the server | Safe for per-user data? | |---|---|---|---| | Module scope | once per page visit | once per process | no | | App-level `provide` from a per-request factory | once per page visit | once per request | yes | | Component state in setup | per instance | per instance, per render | yes | ## Proving the fix 1. Put a deliberately slow data source behind the page so renders overlap. 2. Fire concurrent requests authenticated as two different users. 3. Assert that each response contains only its own user's markers. 4. Keep it running for a while and watch process memory: per-request state should be released when each request ends. A single sequential request per user never exercises the interleaving case, which is why this bug usually surfaces only in production traffic. ## What else counts as module-level state - Composables that declare their `ref`s outside the exported function, deliberately sharing them. - Caches keyed by something other than the request, and plain `let currentUser` variables. - A router or other instance created at module scope instead of inside the factory. - Third-party libraries that keep their own singletons. Immutable configuration and pure helper functions are fine at module scope. Store libraries designed for SSR follow the same per-request rule.

  • Could you create the Vue app once at server start and call renderToString on it for every request?
    No. Everything attached to that app, its provided stores, plugin state and router position, would be shared by every request, which is the same pollution one level up. Each render also provides the SSR context on the app again, which Vue warns about in development as an existing provide being overwritten. Call the factory per request.
  • How would you find cross-request pollution in an existing Vue SSR codebase?
    Search for `reactive(`, `ref(` and mutable `let` declarations at module scope, including composables that keep refs outside their function, and check what creates the router and stores. Then reproduce it: fire concurrent requests as two different users against a slow endpoint and compare each response's personalised markup.
  • Why does the module-level store pattern work fine in a client-only Vue app?
    Because the browser evaluates your modules fresh for each page visit, and one tab belongs to one user. The singleton therefore lives exactly as long as that user's session in that page, which is the scope you wanted. Only a long-lived server process, which serves many users from one module cache, turns it into shared state.

A module-level store is a whiteboard on the office wall: every visitor who walks in reads and writes the same board. An app per request hands each visitor their own notepad, and wiping the whiteboard between visitors fails as soon as two of them are in the room at once.

saying these in an interview costs you the question

  • Node re-evaluates imported modules for every incoming request.
  • Resetting the shared store at the start of each request makes it safe.
  • Cross-request pollution only happens when you use a store library.
  • One app instance created at boot can safely render every request.
  • Module-level reactive state is a bug in client-only Vue apps too.