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.

Plugin layers A Vue plugin installs one connection per app, provides it under a typed injection key, and exposes useRealtime and useChannel composables that components call without touching the raw socket. Plugin layers app.use(realtime, options) creates one connection per app instance install app.provide(RealtimeKey, client) typed InjectionKey; available to every component provide useRealtime() inject with a clear error if the plugin is missing inject useChannel(name, handler) subscribes; onScopeDispose unsubscribes automatically composable Components call composables; never touch the raw WebSocket consumers Cleanup lives in the composable, so it cannot be forgotten in a component
Components get capabilities, not the socket.

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.tsapp.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.

Channel reference counting Two components subscribe to the notifications channel; only the first causes a server subscription, and only when the second of them unmounts does the client send an unsubscribe. Channel reference counting Bell icon Inbox page Plugin client Server useChannel(notifications) sub notifications useChannel(notifications) already subscribed unmount: off() unmount: off() unsub notifications The server sees one subscription per channel per connection, however many components listen
Local listeners are counted; the server hears only first-in and last-out.

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.

Sharing a connection in Vue Comparison of a module singleton, globalProperties and provide/inject with a composable on type safety, scoping to one app instance and automatic listener cleanup. Sharing a connection in Vue Typed Per app instance Auto cleanup Module singleton yes no manual globalProperties needs augmentation yes manual provide / inject + composable InjectionKey yes onScopeDispose Only the composable approach puts cleanup somewhere components cannot skip it
provide/inject scopes the connection; the composable scopes the listeners.

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.

Back to Vue 3 Composables for Real-Time.