Live Data Updates
A page showing data that changed somewhere else should not need a refresh button. This guide wires a backend event to a subscribed page, so the page refetches the moment something moves.
Drift-tested
The SDK surface on this page is verified in CI against the shipped sources. See Keeping this page honest.
Known limits
The consciously accepted weaknesses in the comms platform — including the 12-hour revoked-membership websocket window that applies to data topics too — are recorded in Communications — Accepted Risks.
This is not a message queue
Live data is a hint channel. It exists to tell a page "something you are looking at changed" so the page can go and ask your API what it changed to. It carries no durability, no ordering, and no history.
If you are reaching for it to move data, stop and read the next section carefully. If you want something the user can come back to tomorrow, you want notifications instead.
The D7 contract
Six properties. Every one of them is a decision, not an implementation gap:
1. Delivery is at-most-once. A hint is published to whoever is connected at that instant. There is no queue, no ack and no redelivery.
2. It is lossy by design. A tab that was closed, mid-reconnect, or behind a slow consumer simply misses the hint. Nothing accumulates for it. This is what lets the transport stay a thin fan-out with no storage behind it.
3. Payloads must be non-secret. A published payload is visible to every tenant member with access to the application — not just the user who caused the change, and not filtered by any per-record permission your app enforces. There is no per-recipient targeting on this channel at all. Never publish a record's contents, a personal detail, or anything you would gate behind an authorization check.
4. The pattern is refetch-on-hint. Treat the payload as a routing key, not as data. Use it to decide what to refetch, then refetch through your own API, which applies your own authorization:
comms.subscribe("orders", (payload) => {
if (payload.orderId === currentOrderId) refetchOrder(currentOrderId);
});5. Your API remains the source of truth. A page's state must be derivable from your API alone. If a hint never arrives, the page is stale until the next poll, navigation or user action — degraded, never wrong. A page that can only be correct if it received every hint is a page built on the wrong channel.
6. Cross-tenant reach is unrepresentable. Clients subscribe with bare topic names. The hub expands orders into a tenant- and application-scoped wire topic using the identity fixed when the socket connected. A client cannot name another tenant's topic — not "is blocked from", cannot name.
Publish from your backend
Node / Express
await comms.data.publish("orders", { orderId: order.id }, { tenantId });Same client as notifications — createCommsClient({ managerUrl, machineUserToken, applicationId }), then the data namespace instead of notifications.
payload is positional, not optional
A bare signal with no payload is written with an explicit undefined:
await comms.data.publish("orders", undefined, { tenantId });tenantId is required, and TypeScript forbids a required parameter after an optional one — so payload sits in the middle as T | undefined. Sniffing the argument shapes at runtime was rejected deliberately: a payload could legitimately contain a tenantId key, which would make disambiguation guesswork. When payload is undefined the key is omitted from the wire body entirely.
Go
err := comms.Publish(ctx, productify.PublishRequest{
TenantID: order.TenantID,
Topic: "orders",
Payload: map[string]any{"orderId": order.ID},
})Publish is a method on the same CommsClient value you use for Emit.
Publishing returns as soon as the hint is on the bus. There is no delivery report, and no database row is written — that is property 1 and 2 made structural rather than promised.
Subscribe in the browser
import { getCommsClient } from "@productifyfw/core";
const comms = getCommsClient();
const stop = comms.subscribe("orders", (payload, message) => {
console.log(message.topic, message.published_at);
refetchOrders();
});subscribe returns an unsubscribe function. Subscribe and unsubscribe are idempotent, and a connection may hold up to 64 topics.
The callback's second argument carries the bare topic and the published_at timestamp. You never see the expanded wire topic; you never send one either.
Reconnects and onResync
The client reconnects on its own with jittered backoff, and re-subscribes everything it held. Because hints published while it was away are gone (property 2), it then emits a resync:
comms.onResync((topics) => {
for (const topic of topics) refetchFor(topic);
});Treat onResync as "you may have missed something — refetch once". It is the mechanism that makes a lossy channel safe: the gap closes at the next reconnect rather than persisting until the user reloads.
Tenant switches
Identity is fixed when the socket connects, so switching tenant is a teardown and a fresh connection, not a re-subscription on the existing one. In the tab that calls switchTenant, @productifyfw/core does this for you: the old socket is dropped and remade under the new identity, subscriptions are restored against the new tenant's topics, and onResync fires so your pages refetch.
Other tabs are a different story, and this is the part worth reading twice. The socket is shared browser-wide, so when one tab switches tenant, every other tab is left rendering a document for the old tenant. Those tabs are detached and deliberately not reconnected — feeding them the new tenant's stream is exactly the cross-tenant leak the shared worker exists to prevent. They receive nothing further, silently, until they do something about it.
onIdentityChange is how they find out:
comms.onIdentityChange(() => {
// This tab is now detached. Reload, or move the app to the new tenant and
// re-create the client with resetCommsClient().
location.reload();
});The callback is replayed immediately if the change already happened, so a late subscriber cannot miss it and sit there quietly rendering a tenant it no longer has. A live-updating view that does not handle onIdentityChange will appear to work and then go stale without any error — this is the single most likely way to misuse the client.
The practical consequence of all of it: a subscription made before a switch cannot leak data from after it, because the topics it resolves to are different strings on the new connection, and a tab that did not switch is cut off rather than re-pointed.
Topic rules
| Rule | Value |
|---|---|
| Topic pattern | ^[a-z0-9._-]{1,64}$ |
| Colons | forbidden — : is the server's namespace separator |
| Payload size | ≤ 4 KB serialized |
| Subscriptions per connection | 64 |
Both SDKs enforce all of these locally before sending, raising the same typed errors described in the notifications guide.
Choosing between the two channels
| Notifications | Live data | |
|---|---|---|
| Durable | yes — a row with read state | no |
| Delivery | at-least-once to the inbox | at-most-once, best effort |
| Targeted | per user | per tenant + app, everyone connected |
| Payload sensitivity | scoped to named recipients | non-secret only |
| Falls back to email | yes | no |
| Use for | "your order shipped" | "the orders list changed" |
When in doubt: if a user would be annoyed to have missed it, it is a notification.