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.
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.
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.
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.
Related #
- Vue 3 WebSocket Plugin with provide/inject — the plain Vue version of the plugin.
- Syncing a Pinia Store with WebSockets — store-owned connections, SSR-safe.
- Using WebSockets in the Next.js App Router — the same split in React.
- Reconciling Snapshots and Deltas over WebSockets — the resume protocol.
Back to Vue 3 Composables for Real-Time.