Nuxt 3 WebSocket integration #

You add a WebSocket to a Nuxt 3 page and hit one of three problems. Server rendering fails with WebSocket is not defined, because the page’s setup code runs on the server first. Or it renders, but the server opened a socket per request that nobody ever closes, and the Nitro process slowly fills with idle connections. Or everything works, but the first second after load shows stale data, because the server-rendered HTML was produced from one snapshot and the live stream starts from a different point. Nuxt runs your code in two environments and gives you the tools to split it cleanly: client-only plugins for the connection, useFetch or useAsyncData for the initial state, and — since Nitro gained WebSocket support — a place to host the socket server in the same project.

Root cause #

A Nuxt page’s <script setup> runs on the server to produce HTML and again in the browser during hydration. Anything in it that touches browser APIs breaks the server pass, and anything with side effects runs twice: a new WebSocket() in setup is an error on the server under Node’s older runtimes and a leaked server-side connection on runtimes that do have a global WebSocket. Plugins behave the same way unless they are explicitly marked as client-only.

The stale-first-second problem is about two sources that were never stitched together. useFetch captures a snapshot on the server, serializes it into the payload and reuses it in the browser, which is exactly right. The WebSocket then connects and starts sending new events — but whatever happened between the server’s fetch and the socket’s open is in neither.

Server render, hydrate, resume The browser requests the page; Nuxt fetches a snapshot at sequence ninety on the server and sends HTML with the payload; the browser hydrates from the payload, then a client-only plugin connects with afterSeq ninety and receives the missed events before live updates. Server render, hydrate, resume Browser Nuxt SSR API Nitro WS GET /board useFetch snapshot (seq 90) HTML + payload seq 90 hydrate from payload client plugin: connect afterSeq=90 replay 91–93, then live The payload's sequence number is the handoff point between SSR data and the live stream
Fetch once on the server, connect once in the browser, and resume from the exact point in between.

Resolution #

Put the connection in a client-only plugin (the .client.ts suffix makes Nuxt skip it on the server), fetch initial data with useFetch so it is rendered and hydrated from the payload, and connect with the snapshot’s sequence number so the server can replay the gap.

// plugins/realtime.client.ts — never executed during SSR
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
const url = config.public.wsUrl as string; // runtime config, not a build-time constant
const listeners = new Map<string, Set<(d: unknown) => void>>();
let ws: WebSocket | null = null;
let afterSeq = 0;

function connect() {
ws = new WebSocket(`${url}?afterSeq=${afterSeq}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data) as { stream: string; seq: number; data: unknown };
if (msg.seq <= afterSeq) return; // already in the SSR payload
afterSeq = msg.seq;
listeners.get(msg.stream)?.forEach((fn) => fn(msg.data));
};
ws.onclose = () => setTimeout(connect, 1_000 + Math.random() * 2_000); // simple jittered retry
}

return {
provide: {
realtime: {
// Pages call this with the seq their useFetch snapshot ended at.
start(fromSeq: number) { if (!ws) { afterSeq = fromSeq; connect(); } },
on(stream: string, fn: (d: unknown) => void) {
let set = listeners.get(stream);
if (!set) listeners.set(stream, (set = new Set()));
set.add(fn);
return () => set!.delete(fn);
},
},
},
};
});
// composables/useLiveBoard.ts
export async function useLiveBoard(boardId: string) {
// Runs on the server during SSR and is reused from the payload on hydration.
const { data } = await useFetch(`/api/boards/${boardId}`, { key: `board-${boardId}` });
const cards = ref(data.value?.cards ?? []);

onMounted(() => {
const { $realtime } = useNuxtApp(); // exists only in the browser
const off = $realtime.on(`board:${boardId}`, (card: any) => {
const i = cards.value.findIndex((c: any) => c.id === card.id);
if (i >= 0) cards.value[i] = card; else cards.value.push(card);
});
$realtime.start(data.value?.seq ?? 0);
onUnmounted(off);
});

return { cards };
}

Declare wsUrl under runtimeConfig.public in nuxt.config.ts, so it can differ per environment without rebuilding. Everything that touches $realtime sits inside onMounted, which never runs on the server, so the page renders identically on both sides and hydration never mismatches. The same reasoning applies in other meta-frameworks; compare SvelteKit SSR-safe WebSocket initialization and using WebSockets in the Next.js App Router.

Hosting the socket server in Nitro #

Nitro, Nuxt’s server engine, supports WebSocket handlers through crossws. Enable it with nitro: { experimental: { websocket: true } } in nuxt.config.ts and define a handler in server/routes/_ws.ts:

// server/routes/_ws.ts — runs in Nitro, not in the browser
export default defineWebSocketHandler({
open(peer) { peer.subscribe('board:42'); }, // crossws pub/sub topics
message(peer, message) {
const msg = message.json() as { op: string; stream?: string };
if (msg.op === 'sub' && msg.stream) peer.subscribe(msg.stream);
},
close(peer) { /* per-peer cleanup */ },
});

This is convenient for small deployments and prototypes. For production fleets, most teams still run the socket server as a separate service so it can scale, deploy and drain independently of page rendering, and because serverless Nuxt deployment targets cannot hold long-lived connections at all.

Where Nuxt code runs Where different pieces of Nuxt code execute — script setup, onMounted, client plugins, universal plugins and Nitro WebSocket routes — and what each should be used for. Where Nuxt code runs Server (SSR) Browser Use for script setup body yes yes useFetch, refs onMounted no yes subscribe, start socket plugins/*.client.ts no yes the connection plugins/*.ts yes yes never a socket server/routes/_ws.ts Nitro process no socket server A socket client belongs only in code that runs exclusively in the browser
Match each piece of the real-time feature to the environment that can run it.

Edge cases #

Client-side navigation. Plugins live for the whole app session, so the connection persists across page navigations, while page listeners subscribe and unsubscribe with each page. Make sure start is idempotent (as above) so visiting a second live page does not open a second socket, and send each page’s snapshot sequence per stream if pages track different streams.

Payload size. Everything useFetch returns is serialized into the HTML payload. For large initial datasets, fetch a smaller first page on the server and load the rest on the client, or the HTML grows with your data.

Nitro WebSocket and proxies. A Nitro-hosted socket still needs proxy upgrade headers and idle timeouts configured in front of it, exactly like any other Node server; see configuring nginx for WebSocket upgrades.

Verification #

Run a production build (nuxi build then node .output/server/index.mjs) and view source on a live page: the initial cards must be in the HTML. The console must show no hydration mismatch warnings. In the Network panel, exactly one WebSocket should open after load, carrying the afterSeq value that matches the payload. On the server, count open sockets on the Nitro process — it should equal the number of browser tabs, never grow per page request.

The window that afterSeq protects is short but real, and throttling the network in DevTools makes it long enough to test deliberately.

The gap between snapshot and socket A snapshot is taken on the server at zero milliseconds, HTML is sent at 180, event 91 is published at 650, hydration finishes at 900 and the socket opens at 1250 and replays event 91. The gap between snapshot and socket neither source has event 91 yet useFetch snapshot on server (0 ms) HTML sent (180 ms) event 91 published (650 ms) hydration done (900 ms) socket open, replay 91 (1250 ms) Without afterSeq, event 91 would appear only after the next full reload
Over a second on a slow connection — long enough for real events to fall through.

Operational checklist #

FAQ #

How do I use WebSockets in Nuxt 3? #

Create the client in a plugins/name.client.ts file so it only runs in the browser, provide it through nuxtApp, and subscribe from onMounted in pages or composables. For the server, either enable Nitro’s experimental WebSocket handlers or run a separate socket service.

Why is WebSocket undefined during Nuxt SSR? #

Your code creating the socket runs on the server during rendering. Move it into a client-only plugin or into onMounted, which Nuxt never executes on the server.

Can Nitro’s WebSocket handler scale across instances? #

Each Nitro instance holds its own peers, and crossws topics are local to the process. For multiple instances you need a shared pub/sub layer, the pattern in scaling WebSocket broadcast with Redis pub/sub.

Should I use useWebSocket from VueUse in Nuxt? #

It works, as long as it runs only on the client — call it inside onMounted or a client-only component (<ClientOnly>). The resume-from-sequence logic is still yours to add.

Back to Vue 3 Composables for Real-Time.