One worker, every tab
spawnShared() connects all tabs of your origin to a single worker instance — a shared cache, one WebSocket, cross-tab coordination. Where the browser has SharedWorker, threadsx uses it directly. Where it is missing (notably Chrome on Android), one tab is elected leader via the Web Locks API, owns a regular dedicated worker, and serves the other tabs over a BroadcastChannel. Your code is the same either way.
// master.js — run in every tab
import { spawnShared, Thread } from "threadsx"
const counter = await spawnShared(
({ shared }) => shared
? new SharedWorker(new URL("./workers/counter.js", import.meta.url), { name: "counter" })
: new Worker(new URL("./workers/counter.js", import.meta.url)),
{ name: "counter" }
)
await counter.increment() // all tabs hit the same instance
// workers/counter.js
import { exposeShared } from "threadsx/worker"
let count = 0
const { broadcast } = exposeShared({
increment() {
count += 1
broadcast({ count }) // push to every connected tab
return count
},
getCount() {
return count
}
})
The factory and the name
The first argument is a factory instead of a worker instance, for two reasons:
- On the fallback path only the elected leader tab may actually create the worker — the factory runs there, with
{ shared: false }. - Bundlers detect workers statically, so the literal
new SharedWorker(new URL(…))andnew Worker(new URL(…))calls must appear in your code.
options.name is required. It is the cross-tab identity: it names the SharedWorker, the fallback BroadcastChannel, and the leader-election lock. Two calls with the same name reach the same instance; different names get different instances.
Broadcast events
exposeShared() returns a handle with broadcast(value), which pushes a message to every connected tab. Subscribe with Thread.broadcasts():
Thread.broadcasts(counter).subscribe(({ count }) => {
document.title = `Count: ${count}`
})
Broadcasts are fire-and-forget events from the worker — independent of any call. For request/response, just call the exposed functions; for streamed results per tab, return an observable from a worker function as usual.
Lifecycle
Thread.terminate(thread)disconnects this tab only. The worker keeps running while any other tab is connected. Browsers shut aSharedWorkerdown once its last client is gone.- Observables returned by worker functions are per-tab jobs: unsubscribing in one tab cancels only that tab’s subscription.
- Tabs that disappear without disconnecting (a crash, a mobile OOM kill) are pruned by a heartbeat after ~45 seconds: their jobs are canceled and
connectionCount()drops. Back/forward-cache navigations do not disconnect — a page restored via the Back button keeps its working connection. Thread.broadcasts()completes when the thread terminates, likeThread.events().- On the fallback path, if the leader tab closes, another tab takes over and respawns the worker with fresh state. Calls that were in flight reject with
SharedWorkerLeaderLostError; calls made afterwards work against the new instance. Keep shared state re-derivable, or persist it (e.g. IndexedDB) if it must survive.
import { SharedWorkerLeaderLostError } from "threadsx"
try {
await counter.increment()
} catch (error) {
if (error instanceof SharedWorkerLeaderLostError) {
await counter.increment() // the respawned worker serves this one
} else {
throw error
}
}
Limitations
- Browser only. In Node.js there is no
SharedWorker;spawnShared()throws — usespawn()there. Transfer()is unsupported on the fallback path:BroadcastChannelstructured-clones values, it cannot transfer them. On the native path transfers work as usual.- Worker state does not survive a fallback leader failover (see above). On the native path the instance lives independently of any particular tab.
TypeScript
spawnShared<T>() is typed like spawn<T>():
type Counter = {
increment(): Promise<number>
getCount(): Promise<number>
}
const counter = await spawnShared<Counter>(factory, { name: "counter" })
const next: number = await counter.increment()