Vue 3 WebSocket plugin with provide/inject #
Your Vue app needs one WebSocket connection for the whole session — notifications, presence, live counters — and every feature team reaches it differently. One imports a module-level singleton, another pulls it from app.config.globalProperties.$socket inside the Options API, a third opens its own. Listeners are added in mounted and never removed, so navigating back and forth multiplies them, and tests cannot replace the socket without monkey-patching modules. A Vue plugin that creates the connection once, provides it under a typed injection key, and ships a composable that subscribes with automatic cleanup gives every team the same API and makes the lifecycle impossible to get wrong.
Root cause #
Vue offers several ways to share an object across an app, and they have different failure modes for long-lived resources. Module singletons work but are global to the JavaScript module graph, not to the Vue app instance, so two app instances (micro-frontends, tests, SSR) share one socket, and tests must reset module state. globalProperties exposes the object to templates and the Options API but is untyped by default and invisible to the Composition API’s setup. And any approach that hands out the raw socket leaves each component responsible for removing its own listeners, which is exactly the step that gets forgotten — the leak described in finding WebSocket listener leaks in DevTools.
provide/inject at the app level solves the scoping problem: the connection belongs to one app instance, and a test can install the plugin with a fake. A composable built on onScopeDispose solves the cleanup problem: subscriptions end when the component’s effect scope ends, with no code in the component.
Resolution #
The plugin below wraps the connection in a small client with channel subscriptions and a reactive status. It is installed once, provided with a typed InjectionKey, and consumed through two composables.
// realtime/plugin.ts
import { type App, type InjectionKey, inject, onScopeDispose, readonly, ref, type Ref } from 'vue';
type Handler = (data: unknown) => void;
export interface RealtimeClient {
status: Readonly<Ref<'connecting' | 'open' | 'closed'>>;
subscribe(channel: string, handler: Handler): () => void;
send(channel: string, data: unknown): void;
close(): void;
}
export const RealtimeKey: InjectionKey<RealtimeClient> = Symbol('realtime');
export function createRealtimeClient(url: string): RealtimeClient {
const status = ref<'connecting' | 'open' | 'closed'>('connecting');
const handlers = new Map<string, Set<Handler>>();
const ws = new WebSocket(url);
ws.onopen = () => {
status.value = 'open';
// Re-announce every active channel (covers subscriptions made before open).
for (const channel of handlers.keys()) ws.send(JSON.stringify({ op: 'sub', channel }));
};
ws.onclose = () => { status.value = 'closed'; };
ws.onmessage = (e) => {
const { channel, data } = JSON.parse(e.data);
handlers.get(channel)?.forEach((h) => h(data));
};
return {
status: readonly(status),
subscribe(channel, handler) {
let set = handlers.get(channel);
if (!set) {
handlers.set(channel, (set = new Set()));
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 'sub', channel }));
}
set.add(handler);
return () => {
set!.delete(handler);
if (set!.size === 0) { // last listener: tell the server
handlers.delete(channel);
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 'unsub', channel }));
}
};
},
send(channel, data) { ws.send(JSON.stringify({ op: 'pub', channel, data })); },
close() { ws.close(1000, 'app unmounted'); },
};
}
export const realtime = {
install(app: App, options: { url: string } | { client: RealtimeClient }) {
// Tests pass a fake client; production passes a URL.
const client = 'client' in options ? options.client : createRealtimeClient(options.url);
app.provide(RealtimeKey, client);
const unmount = app.unmount.bind(app);
app.unmount = () => { client.close(); unmount(); }; // close with the app
},
};
export function useRealtime(): RealtimeClient {
const client = inject(RealtimeKey);
if (!client) throw new Error('useRealtime() called without app.use(realtime)');
return client;
}
// Subscribe for the lifetime of the calling component (or any effect scope).
export function useChannel<T>(channel: string, handler: (data: T) => void) {
const client = useRealtime();
const off = client.subscribe(channel, handler as Handler);
onScopeDispose(off); // unmount = unsubscribe, always
return client.status;
}
Installing it is one line in main.ts — app.use(realtime, { url: import.meta.env.VITE_WS_URL }) — and a component subscribes with useChannel('notifications', (n) => items.value.unshift(n)). When the component unmounts, onScopeDispose removes the handler, and if it was the last one for that channel the server is told to stop sending it. Reference-counting channels on the client like this keeps server fan-out proportional to what is actually on screen.
The same client can feed a Pinia store for shared state, as in syncing a Pinia store with WebSockets; the plugin supplies the connection and the store owns the data.
Edge cases #
Server-side rendering. Creating the WebSocket inside install would open a socket on the server for every rendered request. For SSR apps, install a no-op client on the server and the real one in a client-only plugin; Nuxt handles this with .client.ts plugin files, covered in Nuxt 3 WebSocket integration.
Calling composables outside setup. inject only works during setup (or inside app.runWithContext). Calling useChannel from an event handler or a setTimeout throws. If a subscription must start later, create an effectScope in setup and run the composable inside it when needed, stopping the scope to unsubscribe.
Reconnects. The client above re-announces channels on open, so adding reconnection is a matter of replacing ws on close with a backoff timer and re-running the handlers setup — see a Vue 3 useWebSocket composable with auto-reconnect for the backoff logic.
Verification #
In a component test, install the plugin with a fake client and assert that mounting subscribes and unmounting unsubscribes:
import { mount } from '@vue/test-utils';
import { realtime, useChannel } from '@/realtime/plugin';
it('unsubscribes when the component unmounts', () => {
const off = vi.fn();
const fake = { status: ref('open'), subscribe: vi.fn(() => off), send: vi.fn(), close: vi.fn() };
const Comp = { setup() { useChannel('alerts', () => {}); return () => null; } };
const wrapper = mount(Comp, { global: { plugins: [[realtime, { client: fake }]] } });
expect(fake.subscribe).toHaveBeenCalledWith('alerts', expect.any(Function));
wrapper.unmount();
expect(off).toHaveBeenCalledOnce();
});
In the browser, navigate repeatedly between views that use the same channel and check the WS frames in DevTools: exactly one sub when the first view mounts and one unsub when the last leaves, never a growing stream of duplicates.
Operational checklist #
FAQ #
Why provide/inject instead of a global import? #
Injection scopes the connection to one app instance, which matters for tests, SSR and pages hosting several Vue apps, and it lets you install a fake client without touching module state.
Can I use this from the Options API? #
Yes: call useRealtime() or useChannel() from a setup() function in the component, or use inject: { realtime: { from: RealtimeKey } } and manage cleanup in unmounted. The composable is safer because cleanup is automatic.
Is onScopeDispose the same as onUnmounted? #
For components they fire at effectively the same time, but onScopeDispose also works in any effectScope, including composables used inside Pinia setup stores or manually created scopes. That makes it the better hook for reusable composables.
How many channels per connection is reasonable? #
Hundreds are fine on the client; the constraint is usually server-side fan-out and authorization cost per subscription. Unsubscribing channels that are no longer on screen, as above, keeps the number proportional to the visible UI.
Related #
- Syncing a Pinia Store with WebSockets — shared state on top of the plugin.
- Vue 3 useWebSocket Composable with Auto-Reconnect — reconnection logic to add to the client.
- Nuxt 3 WebSocket Integration — the SSR-aware version.
- Finding WebSocket Listener Leaks in DevTools — the bug this design prevents.
Back to Vue 3 Composables for Real-Time.