Simulcast
Provider-independent realtime subscriptions for TypeScript.
Simulcast owns the lifecycle of realtime subscriptions. A client holds one connection session. Components listening to the same channel share one native subscription, and the last one to leave releases it. The same API works from React, Vue, Solid, Svelte, or plain TypeScript, over eleven provider adapters.
Get started · Live demo · Adapters · Devtools · GitHub
pnpm add @priemskiyyy/simulcast @priemskiyyy/simulcast-react @priemskiyyy/simulcast-broadcast-channelOne lifecycle, any provider
The consumer code does not change when the adapter does.
import { RealtimeClient } from "@priemskiyyy/simulcast";
import { mqtt } from "@priemskiyyy/simulcast-mqtt";
export const realtime = new RealtimeClient({
adapter: mqtt({ url: "wss://broker.example.com/mqtt" }),
});import { RealtimeClient } from "@priemskiyyy/simulcast";
import { centrifugo } from "@priemskiyyy/simulcast-centrifugo";
export const realtime = new RealtimeClient({
adapter: centrifugo({ transport: "wss://example.com/connection/websocket" }),
});import { useChannel } from "@priemskiyyy/simulcast-react";
export const Room = () => {
useChannel("rooms:demo", (message) => console.log(message));
return null;
};What ports here is the lifecycle. An MQTT channel is a topic filter and a Centrifugo channel is a subscription, and Simulcast owns only when each one is created and released.
Consumers share one native subscription
Component A --+
Component B --+-- rooms:123 -- one native subscription
Component C --+
A unmounts -> subscription remains
B unmounts -> subscription remains
C unmounts -> native subscription releasedStatus observers such as useChannelStatus watch a channel without creating demand, so a badge or devtools panel never keeps a subscription alive.
What Simulcast normalizes
| Normalized | Left to the provider |
|---|---|
| Connection and session ownership | Delivery guarantees and QoS |
| Logical channels and subscription ownership | Presence and history |
| Shared consumers and deterministic cleanup | RPC, history, and how recovery happens |
| Coarse connection and channel state | Authorization models |
| Publication delivery with native context | Publishing semantics |
| Diagnostics and devtools |
React / Vue / Solid / Svelte / TypeScript
|
v
Simulcast core -- Devtools
|
+-----------+-----------+
| | |
Centrifugo MQTT Pusher ...Why not use the provider SDK directly?
You still do. Simulcast never replaces Centrifuge, Pusher, or mqtt.js. It decides when their connections and subscriptions are created and released, and leaves the rest to them. What it adds:
- Subscriptions that follow a component's lifecycle.
- One native subscription per channel, however many components listen.
- Cleanup that is deterministic and idempotent.
- Consumer code that survives a change of provider or framework.
- Diagnostics and devtools that read the same way across providers.
- A mock adapter for tests, and one contract for writing new adapters.
Presence, history, QoS, RPC, and publishing stay on the native client, which every hook can reach.
When not to use Simulcast
- One subscription, listened to in one place. The SDK alone is less code.
- Provider-specific features in every component. There is no portability left to gain.
- A need for uniform delivery guarantees or presence across providers. Simulcast does not normalize those.
- No component lifecycle to integrate with. A subscription map you write yourself is enough.
Choose an adapter · Write your own · Open the live demo · Read the architecture