Nuxt useState vs ref(): Why Server State Leaks Across Users
A module-scope ref() in Nuxt is shared by every request that hits your server. Learn why useState isolates state per user, and how to fix the leak.

Two people load your Nuxt storefront within the same second. One of them refreshes their cart and sees someone else's items in it. There's no stack trace, no 500, no failed request in the network tab — just the wrong data, silently, for one unlucky user. You spend an hour on localhost trying to reproduce it and can't, because on localhost you only ever have one request in flight at a time.
The bug isn't in your cart logic. It's in where the state that holds the cart was declared.
By the end of this article you'll be able to:
- Explain why a
ref()or plain object declared at module scope in Nuxt gets shared by every request your server handles, not just the one that created it - Use
useState()correctly — what its key does, when to omit it, and what it actually isolates - Recognize the same danger inside Nitro server routes, not just components and composables
- Tell the difference between module-scope state that's dangerous (per-user data) and module-scope state that's fine (shared caches, constants)
- Reach for the right tool —
useState,event.context, or a plainref— for a given piece of state
You've built at least one Nuxt page and used a composable. You don't need prior SSR experience — this article builds the mental model for "what runs where" from the ground up.
This article is written against Nuxt 4.5.x (verified against the nuxt package's release history on npm and GitHub in August 2026; latest patch at the time of writing is 4.5.2, with Nuxt 3 having reached end-of-life on July 31, 2026). Code and directory paths use the Nuxt 4 app/ convention (app/composables/, app/plugins/, app/pages/ — server/ stays at the project root, outside app/). If your project still uses the flat Nuxt 3 layout (composables/, plugins/, pages/ at the root), the same code works unchanged; only the folder location differs, noted once below.
- The problem: a cart that isn't yours
- The mental model: one process, many requests
- Stage 1: reproducing the leak
- Stage 2: what "per request" actually means
- Stage 3: fixing it with useState
- Stage 4: the same bug in a Nitro server route
- Stage 5: what module scope is actually fine for
- Edge cases and gotchas
- Best practices: which tool, for what
- FAQ
- Cheat sheet
Here's a composable that looks completely ordinary:
Used from a page:
On your machine, in one browser tab, this works. Add an item, see it in the cart, refresh, it's still there for the length of the session. Nothing looks wrong.
Now imagine two real users, User A and User B, hitting your server within the same few milliseconds — entirely realistic under normal traffic. const cart = ref([]) at the top of useCart.ts runs once, the first time Node imports that file at server startup. Every request after that reuses the exact same ref object. User A adds an item; for a brief window, User B's server-rendered HTML can include it. Low traffic hides it. A few hundred concurrent requests, and it stops being rare.
The mental model: a Nuxt server (via Nitro) is one long-running process — or, in serverless, one warm function instance — that services many requests by interleaving them, not one process per request. Every await in your server-side code is a point where Node can start working on a different request before yours resumes. Anything you declare at module scope — outside a component's setup(), outside a composable function's body, outside defineEventHandler — is evaluated exactly once, when the module is first imported, and lives for the lifetime of the process. It is scoped to the server, not to the request.
A ref() created inside setup() or inside a composable function's body is different: it's created fresh every time that function runs. But useCart()'s cart isn't created inside the function — it's created at the top of the file, outside useCart(), so calling useCart() a second time just returns a reference to the same object every time, request after request.
Key concept: "module scope" and "request scope" are not the same lifetime, and the bug is always a state variable that was written as if they were.
To see this concretely without waiting for real traffic, simulate two overlapping requests directly:
Run this and both logs print ['sku-A', 'sku-B']. User B, who never added sku-A, sees it anyway — because cart was never theirs alone. This is the entire bug in twelve lines, stripped of Nuxt: shared mutable state plus concurrent execution.
Nuxt does create a fresh Vue app instance per request on the server — that part is correctly isolated. Component instances, their setup() locals, and anything created inside a composable function's own body are all request-scoped, because the function runs again for every render. The trap is specifically values created outside any function — they exist before any app instance does.
So the fix isn't "avoid ref()" — ref() inside setup() is fine. The fix is a tool that's request-scoped and still declared once, at the top of a composable, for convenience. That's useState.
Two changes matter. First, cart is now created inside useCart() — but useState doesn't just make a new plain ref each call; it looks up (or creates) a value keyed 'cart' inside the current Nuxt app instance's state, and since each request gets its own app instance, each request gets its own 'cart' entry. Second, the initializer () => [] only runs the first time that key is requested per app instance.
Under the hood, useState(key, init) stores its value in nuxtApp.payload.state[key]. The server serializes that payload into the HTML it sends down; the client reads the same payload during hydration and reuses the value instead of re-running the initializer — no duplicate computation, no flash of different content.
Key concept: useState's key is the isolation boundary. Two calls to useState('cart', ...) anywhere in your app — same component, different components, a plugin — return the same reactive value within one request/app-instance, and a different value than the same key in a different request. Omit the key and Nuxt auto-generates one from the call site, but an explicit string is worth the extra characters: it's what you'll grep for, and it avoids two unrelated composables accidentally colliding on an auto-generated key that happens to match.
The identical mistake happens in server/api/*.ts files, and it's easy to miss because Nitro handlers look request-scoped even when they aren't:
This particular case is actually intentional module scope — a rate limiter needs one shared counter across all requests, by design. The Map itself isn't the bug; sharing one user's identifiable data (a cart, a session, a "current user" object) this way is. If this file instead cached a full user profile fetched during a request and reused it for the next caller regardless of who they were, that's the same leak, just in server/ instead of app/.
For per-request, server-only data — passed between middleware and handler, never meant to reach the client — use event.context instead:
event is created fresh per request by Nitro, so anything on event.context is automatically request-scoped — no key, no leak, and unlike useState it never gets serialized to the client.
Not everything at the top of a file is a bug. Module scope is right for anything that's the same for every request: constants (configuration, compiled regexes, a parsed schema), stateless utilities (pure functions, nothing to leak), process-wide state by design (a rate limiter's counters, a cache keyed by input — cache.get(productId) is fine, cache.get('currentUser') is not), and connections/clients (a database pool exists precisely to be reused across requests).
The dividing line isn't "was it declared at module scope" — it's "does the value hold one specific user's or request's data." A rate-limit Map keyed by IP is process-wide by design and correct. A ref([]) meant to be "the current user's cart" is process-wide by accident and wrong.
- Serverless doesn't save you. Most providers reuse ("warm") a function container across several invocations for performance, so a module-scope leak can still show up there — just more rarely than on a persistent Node server.
useState's value must be serializable. The payload uses Nuxt'sdevalue-based serializer, which handles plain objects, arrays,Map,Set, andDate— but not functions or class instances with methods. Store data, not behavior.useStateon the client is per browser tab, not per user across tabs. After hydration, calls touseState('cart', ...)anywhere in your app return the same client-side ref for that page load — correct, because a tab belongs to exactly one user. This is a different, safe kind of "shared" than the server-side leak above.- Prerendered (SSG) builds can race too.
nuxi generaterenders multiple routes in parallel; a module-scope value mutated during one route's render can bleed into another route's output — a build-time version of the same bug. - The Nuxt 3 flat layout works identically. On
composables/,plugins/,server/at the project root (no top-levelapp/), everything above holds unchanged — only the folder path differs.
useState(key, init)— any reactive value that's computed during SSR, needs to survive hydration without recomputation, and is specific to the current request/user. Covers most "shared state" you'd otherwise put in a module-scoperef.event.context— per-request data only the server needs (an authenticated user object, a parsed token), never serialized to the client.- A plain
ref()insidesetup()— component-local state with no cross-request concern. Mostrefs in a codebase are exactly this, and never the problem. - Module scope — constants, pure utilities, state genuinely meant to be shared by the whole process (caches keyed by input, pools, rate limiters). Never a specific user's data.
- Audit rule of thumb: grep composables and Nitro handlers for
ref(,reactive(, or a bare object/array literal sitting outside any function. Each hit is either module scope done correctly, or the leak — decide deliberately.
Runs right in your browser — poke at it and watch the concept react live.
Because this bug depends on real concurrency, it's genuinely hard to see by reading code alone — the interactive simulation below runs two "requests" side by side under both storage strategies so you can watch the leak happen and disappear.
Because your dev server usually handles one request from one browser tab at a time. The interleaving that causes the leak needs two requests genuinely overlapping — real concurrent traffic, or a deliberately staggered test like Stage 1's, not a single tab refreshed twice in a row.
Related, not identical. useState is Nuxt's built-in, SSR-safe primitive for a single value with automatic payload serialization. Pinia is a full store library (actions, getters, devtools) that's SSR-safe the same way — each store instance is created per app instance, not at module scope, so it doesn't have this bug either. Use useState for a handful of values; reach for Pinia once there's real store logic to organize.
Not by default — both key their result through the same per-app-instance payload mechanism useState uses internally, so a correctly-keyed call is isolated per request. The risk reappears only if you cache their result yourself in a module-scope variable outside the composable.
That's legitimate module-scope state (Stage 5) — declare and mutate it directly, and skip useState, since useState exists specifically for per-request isolation, the opposite of what you want here.
Nuxt 2 had no useState; the same trap existed around the Vuex store instead. Nuxt 2 reached end-of-life years ago — treat any lingering @nuxtjs/composition-api code as a migration to finish, not a pattern to extend.
Grep composables/, plugins/, and server/ for module-scope ref(, reactive(, or object/array literals, and ask of each: "if two users' requests both touched this at once, would that be correct?" A rate limiter's Map: yes. Anything holding one user's cart, profile, or session: no.
| Situation | Use | Why |
|---|---|---|
| Reactive value computed during SSR, must survive hydration, per-user | useState('key', () => init) | Stored in the per-request payload; auto-serialized to the client |
| Server-only data passed between middleware and handler | event.context.foo = … | Per-request by construction; never serialized to the client |
| Component-local state, no SSR/hydration concern | ref() inside setup() | Created fresh every time the function runs |
Cache keyed by input, same for every user (e.g. cache.get(id)) | Module-scope Map/object | Correct use of process lifetime — nothing user-specific in it |
| Rate limiter, connection pool, compiled config | Module-scope value | Meant to persist across every request by design |
| A value one user's request wrote and a different user's request would wrongly read | Never module scope | This is the leak — move it to useState or event.context |
- Anything declared outside a component's
setup()or a composable's function body runs once, when the server process starts — not once per request. - Nuxt does create a fresh app instance per request; the bug is state that exists before any app instance does.
useState(key, init)is the fix: it's keyed per app instance, so each request gets its own isolated value, and it survives hydration through the SSR payload.- The same trap exists in Nitro server routes (
server/api,server/middleware) — useevent.contextthere for per-request, server-only data. - Module scope isn't wrong by default — it's wrong specifically when it holds one user's data. Caches keyed by input, rate limiters, and connection pools are correct uses of it.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The cart that showed the wrong items wasn't broken because of a race condition in the checkout logic, or a database read gone stale — it was broken because const cart = ref([]) was written one scope too high. Move it inside useCart() behind useState('cart', () => []), and the exact same component code stops being a coin flip under real traffic.
Next Sunday's episode picks up where "per-request" leaves off: how useAsyncData and useFetch decide when two calls are the same request and dedupe them, and when that's the bug instead of the fix.
Has a "how is this even possible" production bug ever turned out to be state declared at the wrong scope? What gave it away?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.