SharedWorkerMiddleware
sharedWorkerMiddleware / syncSharedWorkerMiddleware synchronize storage state between tabs
through a SharedWorker. It is a direct mirror of broadcastMiddleware:
same role, same signature — only the transport differs. Where broadcastMiddleware uses a
BroadcastChannel, sharedWorkerMiddleware routes messages through a single SharedWorker shared
by every tab of the origin.
Like all middlewares, it is wired up at store creation time in the middlewares field.
Which one to import
There are two factories with an identical signature:
syncSharedWorkerMiddleware— for synchronous storages (MemoryStorage,LocalStorage).sharedWorkerMiddleware— for the asynchronousIndexedDBStorage(andWorkerCacheStorage).
import { MemoryStorage, syncSharedWorkerMiddleware } from 'synapse-storage/core'
const storage = new MemoryStorage<TodoState>({
name: 'shared-worker-demo',
initialState: { todos: [], filter: 'all' },
middlewares: () => [
syncSharedWorkerMiddleware({
storageName: 'shared-worker-demo',
storageType: 'memory',
}),
],
})
await storage.initialize()
// Changes are synchronized between tabs
storage.update((s) => { s.todos.push({ id: 't1', title: 'From another tab', done: false }) })The signature is the same { storageType, storageName } as broadcastMiddleware — swapping one
for the other is a one-line change.
What gets synchronized
The behaviour matches broadcastMiddleware exactly:
MemoryStorage — full data synchronization. A newly opened tab requests the current state from the worker (
requestSync) and seeds itself, then stays in sync on every write.LocalStorage / IndexedDB — only a subscriber notification. The data itself is already shared by the browser storage engine; the middleware just tells the subscribers of other tabs to re-read.
N stores over ONE SharedWorker
The key difference from broadcastMiddleware is transport multiplexing. Each store gets its own
logical channel named ${storageType}-${storageName}, but all channels are multiplexed over a
single SharedWorker instance per origin. Ten stores in a tab do not spawn ten workers — they
share one, and messages are demultiplexed by channel name.
// Both stores below travel through the SAME SharedWorker,
// isolated by their channel: 'memory-todos' vs 'memory-settings'.
const todos = new MemoryStorage<TodoState>({
name: 'todos',
initialState: { todos: [], filter: 'all' },
middlewares: () => [syncSharedWorkerMiddleware({ storageType: 'memory', storageName: 'todos' })],
})
const settings = new MemoryStorage<SettingsState>({
name: 'settings',
initialState: { theme: 'light' },
middlewares: () => [syncSharedWorkerMiddleware({ storageType: 'memory', storageName: 'settings' })],
})The channel name is
${storageType}-${storageName}. Two stores with the samestorageType+storageNameshare one channel — keep the pair unique per logical store.
Fallback and SSR
No SharedWorker? The transport transparently falls back to a
BroadcastChannel, so cross-tab sync keeps working in browsers/contexts without SharedWorker support.SSR / no browser APIs? The middleware is a no-op — there is no window, no other tab to sync with, and store creation does not throw.
When to use which
broadcastMiddleware— the simple default. OneBroadcastChannelper store, no worker. Fine for a handful of stores.sharedWorkerMiddleware— when you sync many stores and want them multiplexed over a single SharedWorker instead of many independent channels, or when you already run a SharedWorker and want to reuse it. Cross-tab semantics are identical.
For a live shared cache (not just notifications) held inside the worker itself, see WorkerCacheStorage.
Types
import type { Middleware, SyncMiddleware } from 'synapse-storage/core'
// Both factories take the same props:
interface SharedWorkerMiddlewareProps {
storageType: StorageType // 'memory' | 'localStorage' | 'indexedDB' | string
storageName: string // used together with storageType as the channel key
}
// syncSharedWorkerMiddleware(props): SyncMiddleware — Memory / LocalStorage
// sharedWorkerMiddleware(props): Middleware — IndexedDB (async)