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. Then agents arrive and need task dispatch, so a job queue appears too.
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 realtime messaging infrastructure and a coordination layer for AI agents: pub/sub topics, rooms, presence and delivery guarantees, 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, a backend API and an AI agent runtime, 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, agent approvals | @nolag/iot, @nolag/agents |
| Backend API | Token minting, provisioning, tenant scoping | Control-plane API plus one client per tenant |
| Agent runtime | Orchestrator and a pool of sub-agents | @nolag/agents (Handoff, Observe, Tools, Approve, Blackboard) |
The dashboard is the clearest illustration. It used to open three sockets, one per feature store. It now opens one:
// One core client per tenant carries ALL realtime apps.
// Feature stores no longer own sockets; they attach a wrapper to this client.
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: an app schema with its rooms, topics, presence and replay pre-configured, and an SDK whose methods are the domain rather than the transport.
const chat = new NoLagChat({ client, username })
room.sendMessage('Hello!')The dashboard in this architecture attaches three of them (chat, notifications, dashboard) to a single client, and the agent runtime attaches a fourth. 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. On reconnect the broker restores the connection's previous subscriptions, so a re-scoped client quietly ends up subscribed to the tenant the user just left. 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.
Where the agents come in
The agent runtime is a separate process, which is the honest shape of it: it holds model credentials and database access the browser tier does not need, and it scales on a different curve.
An orchestrator handles a request that needs analysis across many sub-areas of a site. Rather than doing it in one enormous context, it fans the work out to a pool of sub-agents, each with an isolated context, and collects the results.
// Fan work out to a worker pool and await each result.
// Per-task failures are isolated: one bad task never rejects the batch.
export async function dispatchSubAgents(
tasks: SubAgentTask[],
{ timeoutMs = 120_000, onResult }: DispatchOptions = {},
): Promise<SubAgentResult[]> {
const handoff = await getHandoff()
// ... dispatch each task by capability, settle independently
}Two details in there are the whole lesson.
Per-task failure isolation. One sub-agent failing must not reject the batch. If you await a plain Promise.all over model calls, a single timeout throws away eleven good answers. Each task settles on its own and the caller is told which ones came back.
One long-lived Handoff per room. Constructing a new one per call leaks a result listener every time, which is invisible until a long-running process gets slow for no apparent reason. Build it once, rebuild only if the underlying room changes on reconnect.
Neither of those is in a getting-started guide. Both are the kind of thing you learn by running something for a while.
Humans in the loop
Some agent actions should not happen without a person saying yes. The admin console attaches Approve to the relevant room and renders pending requests:
const approve = new agents.Approve(room, 'admin-dashboard')The agent asks, the request appears in a human's queue, and the agent waits. What makes this workable is that it is the same connection and the same room as everything else. The approval is not a separate integration with its own webhook and its own state to reconcile. It is a coordination pattern on infrastructure that is already there.
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 queue or job system for agent task dispatch
- A separate mechanism for human approval gates
- 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.