Realtime tends to arrive in a product one scenario at a time, and each one gets bought or built separately. Chat comes from one vendor. Push notifications from another. Live dashboard updates get hand-rolled on a raw WebSocket. Device telemetry goes over MQTT because that is what the hardware speaks. Each one arrives with its own client library and its own idea of what a connection is.
Every one of those is a different SDK, a different auth model, a different reconnect strategy, a different bill and a different mental model. None of it is your product. All of it is yours to keep working.
The alternative is not "one vendor who can technically do all of it". Any pub/sub primitive can technically do all of it, which is exactly the trap: you end up with one connection and five bespoke implementations on top. The alternative that is actually worth having is one platform where each of those scenarios is already shaped, so you get the consolidation without paying for it in bespoke plumbing.
That is what this post is about. NoLag is open-source realtime messaging: pub/sub topics, rooms, presence and lobbies, with a Blueprint per scenario so chat behaves like chat rather than like topics you wire yourself. The architecture below is a multi-tenant SaaS with a browser dashboard, an admin console and a backend API, running one WebSocket per tenant carrying all of it.
The shape
| Piece | What it carries | How |
|---|---|---|
| Dashboard | Chat, notifications, live dashboard updates | One core client, three wrapper SDKs attached |
| Admin console | Device telemetry | @nolag/iot |
| Backend API | Token minting, tenant scoping | @nolag/core signing keys and access scopes, plus one client per tenant |
The dashboard is the clearest illustration. It used to open three sockets, one per feature store. It now opens one:
import type { NoLagSocket } from '@nolag/js-sdk'
// One core client per tenant carries ALL realtime apps.
// Feature stores no longer own sockets; they attach a wrapper to this client.
// The app names are the app slugs your auth config defines.
export interface RealtimeHandle {
client: NoLagSocket
chatAppName: string
notifyAppName: string
dashboardAppName: string
}The feature stores did not get simpler because we were clever. They got simpler because the connection stopped being their problem. Each one attaches a wrapper SDK, uses it, and detaches. It never calls connect() and never calls disconnect(), so there is exactly one place that owns the socket lifecycle.
That is worth stating as a rule, because it is the thing that makes the whole pattern hold together: one core client, many wrappers, and only the owner touches the socket.
Why this is not just "one connection"
Collapsing five vendors into one socket is only a win if the scenarios stay easy. Otherwise you have traded five well-shaped SDKs for one primitive and a lot of homework.
That is what Blueprints are for. Each one is a use case that has already been thought through: a documented set of topics the scenario needs, a presence shape, and an SDK whose methods are the domain rather than the transport.
import { NoLagChat } from '@nolag/chat'
// `client` is the tenant's core client; the owner has already connected it
const chat = new NoLagChat({ client, appName: chatAppName, username })
await chat.ready()
const room = chat.joinRoom('general')
room.sendMessage('Hello!')The dashboard in this architecture attaches three of them (chat, notifications, dashboard) to a single client, and the admin console attaches a fourth for device telemetry. Nobody on the team picked topic names or designed a presence payload. The consolidation is real because the ease of use survived it.
That is the actual claim worth making: not one connection instead of three, but one place to get realtime, where each scenario still arrives ready to use.
Scoping to a tenant
Every connection is scoped to one tenant, and the browser never holds a long-lived credential. The backend mints a short-lived tenant-scoped client token, the browser connects with it, and the broker enforces the scope. A user with access to twelve tenants still only ever has a connection to one.
Here is the first thing that cost us real time.
Never re-scope a live connection. When a user switches tenant, it is tempting to keep the socket and change what it is subscribed to. Do not. What a connection may reach comes from the token it authenticated with, so a socket opened with a token for the tenant the user just left keeps that tenant's grants however you rearrange its subscriptions, for as long as it stays open. A tenant switch has to be a new client: tear the old one down, mint a token for the new tenant, connect fresh.
Feature stores detect the identity change and re-attach. In Vue that is a shallowRef holding the client, watched by each store; the equivalent in any framework is "treat the client as a value that can be replaced, not as a singleton that lives forever".
The second thing that cost us time
There is a subtle tenancy bug worth describing, because the shape of it generalises well beyond our domain.
An endpoint took a tenant id in the URL and used it for two different things: deciding which tenant's data the caller was allowed to touch, and deciding whose conversation to create. Those look like the same question and are not. The tenant scopes the data. The caller scopes the conversation.
Conflating them meant a support thread got created under the tenant's account while the browser was listening in the viewer's, so the chat opened and simply hung, with nobody on the other end. Nothing errored. The fix was to separate the two explicitly: the URL scopes the tenant, the authenticated actor scopes the conversation.
If you take one thing from this section: when a request carries a tenant id, be precise about whether it is answering "what may I see" or "who am I". Those diverge exactly when it matters most.
What this collapses
Counted against buying realtime a scenario at a time, one tenant-scoped connection on one platform replaces:
- Three sockets in the dashboard, each with its own reconnect and backoff behaviour
- Three vendors, three SDKs, three auth models and three bills
- Three places that had to be told about a tenant switch, and three chances to get it wrong
- A separate transport for device telemetry
None of that is exotic infrastructure. It is all things a team builds because their realtime layer only does one thing, and it is all undifferentiated work.
If you are building something similar
Four things we would tell ourselves at the start:
- Put the connection behind one owner. A store, a service, a module, whatever your framework calls it. Features attach and detach; they never connect.
- Make a tenant switch a new client. Never re-scope a live one.
- Mint short-lived scoped tokens on your backend. The browser should never hold a credential that outlives the session or reaches beyond the current tenant.
- Be precise about what a tenant id in a request means. Data scope and identity scope are different questions that happen to share a parameter.
The architecture above is not the only way to use this. It is the one that fell out of actually building on it, including the parts we got wrong first.