In a Vue 3 SSR app, why does a module-level reactive() notification store leak one user's messages to another, and how must it change?
answer
- modules load once per server process
- createSSRApp runs per request
- cross-request state pollution
- a factory plus app.provide
basics
~20 sOn a server, modules are initialised once at boot and reused by every request, so a module-level reactive() store is shared by all users. Vue calls this cross-request state pollution; create the store per request in the app factory and provide it.
solid answer
~40 sIn the browser each page load evaluates the modules fresh, so a module-level store belongs to one user. A Node SSR server evaluates the modules **once** when it boots and then handles many requests with them, while `createSSRApp()` is called per request. The module-level `reactive()` queue therefore outlives each request: a notification pushed while rendering Alice's page is still there, or even rendered, when Bob's request arrives. The Vue docs call this **cross-request state pollution**. Re-evaluating modules per request would fix it but is costly, so the recommended change is to turn the store into a factory, create it inside the per-request `createApp()` function, register it with `app.provide()`, and `inject()` it in components instead of importing it. Store libraries are designed around the same per-app instance.
code
ts · 7 lines// UNSAFE under SSR: evaluated once per server process
import { reactive } from 'vue'
export const notifications = reactive({ items: [] as string[] })
// Every request's render reads and writes this same array,
// so one user's items can appear in another user's HTML.go deeper
Know that a module-level store is shared by all users when the app renders on a server, and that Vue calls this cross-request state pollution.
Explain the lifetimes: modules load once per server process while createSSRApp runs per request, and why that makes module state outlive a request.
Rewrite the store as a factory created in the per-request app factory, provided with app.provide and injected, and find the module-scoped state that still leaks.
Decide whether SSR plans justify moving shared state into the app instance or a store library now, rather than retrofitting it after a data leak.
## Why the pattern is safe in the browser A module-level store is a **singleton**: one object for the whole lifetime of the module. In a client-side Vue app that lifetime is one page visit by one user, because the browser evaluates the application modules fresh on every page load. One user, one store, no problem. ## What changes on the server With server-side rendering, the same modules also run in a long-lived server process: 1. The server starts and **imports the application modules once**. The top-level `reactive({ items: [] })` runs at that moment. 2. For each HTTP request, the server calls an app factory that runs `createSSRApp()` and renders the app to HTML. 3. Every render imports the same, already-evaluated module, so every request reads and writes **the same store object**. Consequences for a notification queue: - A request that pushes "Password changed for alice@..." leaves that item in the shared array. - The next request, from another user, renders the toast list and shows Alice's message. - Concurrent requests interleave writes, so one response can include another user's data even without a leftover item. - The state grows for as long as the process runs. The Vue SSR guide names this **cross-request state pollution**. ## Why not reload the modules? Re-initialising every module per request would reproduce browser semantics, but module initialisation is costly and the Vue docs rule it out on performance grounds. The fix instead changes **where the state is created**. ## The change: a per-request store ```ts // store.ts import { reactive, readonly } from 'vue' export function createNotificationStore() { const state = reactive({ items: [] as string[] }) return { items: readonly(state).items, notify: (text: string) => { state.items.push(text) } } } ``` ```ts // app.ts (shared by server and client) import { createSSRApp } from 'vue' import App from './App.vue' import { createNotificationStore } from './store' export function createApp() { const app = createSSRApp(App) const notifications = createNotificationStore() app.provide('notifications', notifications) return { app, notifications } } ``` Components call `inject('notifications')` instead of importing a module-level object. Each request calls `createApp()`, so each request gets its own store, and it is discarded with the app when the response is done. Returning the store from the factory also lets the server serialise its state for the client to pick up during hydration. | | module-level store | per-request store | |---|---|---| | Created | once, when the server imports the module | in `createApp()`, per request | | Reached by components through | `import` | `app.provide()` + `inject()` | | Shared between users on the server | yes | no | | Behaviour in the browser | one per page load | one per page load | ## The client side of the same factory The `createApp()` factory is shared by server and client code. In the browser it runs once per page load, so the store is still one per user there. The server returns the store from the factory so its final state can be serialised into the HTML; the client then initialises its own store from that serialised state before hydrating, so both sides render the same notifications. The details of that hand-off belong to SSR state transfer rather than to the store pattern itself. ## What this means for the store-free pattern - A module-level store and a singleton composable are both fine in client-only apps and **unsafe as-is** in SSR. - Moving state into the app instance is the core change; the per-request mechanics (serialising state, hydration, other SSR-safe patterns) are their own topic. - Store libraries such as Pinia are built around a store instance installed per app, which is one of the reasons the Vue docs list SSR support among the reasons to adopt one. - Module-level **constants** and pure functions stay safe; only mutable, user-specific state is the problem. ## How to spot it in review - `reactive(`, `ref(` or a mutable array at the top level of a module that is imported during server rendering. - A singleton composable whose state is declared above the function. - Bug reports of "someone else's name in the header" that cannot be reproduced locally, where only one user is testing.
- In a Vue SSR app, is every module-level value a problem, for example a constant lookup table?No. Module-level code that holds no per-user mutable state is safe: constants, pure functions and configuration read at boot. The risk is state that one request writes and another reads. A module-level computed over shared mutable state is only as safe as that state.
- Why not just clear the module-level store at the start of every request?Requests are handled concurrently in one process, so while one request renders another can push to or clear the same array. Resetting still shares one object between users. Only creating the state per request, inside the app factory, isolates them.
saying these in an interview costs you the question
- Node re-evaluates application modules for every incoming SSR request.
- Clearing the shared store at the start of each request makes it safe.
- readonly() on a module store prevents cross-request pollution.
- A singleton composable is immune because its state sits behind a function.
- Cross-request pollution only affects stores built with a store library.