Background
We use a SharedWorker to subscribe to a WebSocket and share that single connection across multiple tabs. The basic setup looks like this:
const worker = new SharedWorker(
new URL('./websocket.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.port.start();
if (autoConnect) {
worker.port.postMessage({ type: 'connect', url: 'xxx' });
}
However, there’s a small performance overhead when posting a message before the WebSocket connection is initialized. To reduce that extra step, we can pass the connection endpoint directly through the worker’s URL:
// main thread
const url = new URL('./websocket.worker.ts', import.meta.url);
if (autoConnect) {
url.searchParams.set('endpoint', 'xxx');
}
const worker = new SharedWorker(url, { type: 'module' });
worker.port.start();
// worker
const endpoint = new URL(location.href).searchParams.get('endpoint');
if (endpoint) {
createWebsocket(endpoint);
}
This optimization works perfectly in development—but in production, the SharedWorker fails to initialize.
After digging into the build output, I discovered that the websocket.worker.ts file wasn’t transformed by Vite at all. Instead, it was treated as a video transport stream due to its extension.
I tried several variations, but Vite requires that the URL passed to SharedWorker must be statically analyzable at build time. So none of these worked:
const worker = new SharedWorker(
new URL(
`./websocket.worker.ts?endpoint=${autoConnect ? 'xxx' : ''}`,
import.meta.url
),
{ type: 'module' }
);
const worker = new SharedWorker(
new URL('./websocket.worker.ts', import.meta.url).href +
(autoConnect ? '?endpoint=xxx' : ''),
{ type: 'module' }
);
Solution
My final workaround was to proxy the global SharedWorker constructor, intercepting its instantiation and injecting query parameters dynamically.
This approach ensures compatibility with Vite’s static import analysis while retaining the flexibility to pass runtime parameters when needed:
const SharedWorker = new Proxy(globalThis.SharedWorker, {
construct(target, args) {
const [url, options] = args;
if (url instanceof URL && autoConnect) {
url.searchParams.set('endpoint', 'xxx');
}
return new target(url, options);
}
});
const worker = new SharedWorker(
new URL('./websocket.worker.ts', import.meta.url),
{ type: 'module' }
);
Fallback for SharedWorker
As of October 2025, SharedWorker still lacks full browser support, especially on mobile. To ensure broader compatibility, we can gracefully fallback to a regular Web Worker when SharedWorker isn’t available.
const { SharedWorker, Worker } = createWorkerWithParams({
params: { endpoint: 'xxx' }
})
const worker = createUniversalWorker(
() => new SharedWorker(new URL('./websocket.worker.ts', import.meta.url)),
() => new Worker(new URL('./websocket.worker.ts', import.meta.url)),
)
The factory pattern unifies SharedWorker and Worker creation, ensuring compatibility without breaking static import detection.
We need this helper function to inject query parameters into both SharedWorker and Worker constructors:
function createWorkerWithParams(params: Record<string, any>) {
let SharedWorker = globalThis.SharedWorker;
let Worker = globalThis.Worker;
if (SharedWorker) {
SharedWorker = new Proxy(globalThis.SharedWorker, {
construct(target, args) {
const [url, options] = args;
if (url instanceof URL) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
return new target(url, options);
}
});
}
if (Worker) {
Worker = new Proxy(globalThis.Worker, {
construct(target, args) {
const [url, options] = args;
if (url instanceof URL) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
return new target(url, options);
}
});
}
return { SharedWorker, Worker }
}
And another helper function to create a unified interface for SharedWorker and Worker:
function createUniversalWorker(
sharedWorkerFactory: () => SharedWorker,
workerFactory: () => Worker,
) {
if (typeof SharedWorker !== 'undefined') {
const worker = sharedWorkerFactory();
const port = worker.port;
port.start();
// create a unified interface
return {
postMessage: port.postMessage.bind(port),
terminate: port.close.bind(port),
get onmessage() {
return port.onmessage;
},
set onmessage(handler) {
port.onmessage = handler;
},
get onerror() {
return port.onmessageerror;
},
set onerror(handler) {
port.onmessageerror = handler;
},
};
} else {
const worker = workerFactory();
// create a unified interface
return {
postMessage: worker.postMessage.bind(worker),
terminate: worker.terminate.bind(worker),
get onmessage() {
return worker.onmessage;
},
set onmessage(handler) {
worker.onmessage = handler;
},
get onerror() {
return worker.onerror;
},
set onerror(handler) {
worker.onerror = handler;
},
};
}
}