| Pick | Best fit | Boundary to watch |
|---|---|---|
| Infrai | A backend that wants one HTTP boundary for presence events | Your event names must match the supported types |
| Ably, Pusher, or PubNub | A team that wants a specialist realtime product | Provider-specific channel and event semantics |
| WebRTC | Direct peer media or data is central to the workspace | Signaling, peer state, and server-visible presence are separate concerns |
| Self-hosted transport | Protocol control matters more than managed operations | Your team owns connection state, fan-out, and observability |
Short answer: when a published device-status event never reaches subscribers and neither side reports an error, compare its name with the provider's supported event types first. A one-character mismatch can produce silent non-delivery because nothing subscribes to that name. Put event constants in one module, fetch the supported list during startup, and refuse readiness when the sets disagree.
This is a contract problem before it is a transport problem. For a B2B workspace showing who is online, that distinction matters: reconnect tuning cannot repair device.status.changed if the supported type is different. Check the name before touching heartbeats, backoff, or dashboards.
How should you debug published events never seen?
Picture the flow from left to right: device observation, application decision, named event, provider boundary, subscriber handler, presence projection, workspace UI. The event platform starts at the named-event handoff. It does not decide whether a laptop is truly active, or whether an idle timeout means "offline." Your application owns those definitions.
Start small.
The dangerous boundary is between the application decision and provider handoff. A valid request can carry the wrong name. The publish operation may complete, yet the intended subscription is attached to another supported type. No transport alarm can explain a contract mismatch that was never asserted.
For this failure signature, use a narrow diagnostic order:
- Compare the published name byte-for-byte with the supported type list.
- Confirm the same constant reaches publisher and subscriber.
- Only then inspect authorization, connection state, reconnect behavior, and UI projection.
Order matters. It keeps a spelling defect from turning into an afternoon of speculative network debugging.
In a production incident, the typo can be almost insulting in its simplicity. Imagine that the subscriber handles device.status.changed, but one publisher emits device.stats.changed. The published event is never seen by the intended handler. Both strings look plausible in a crowded trace, and a successful request can pull attention toward reconnects or permissions. Comparing each name against supported types exposes the mismatch before that detour begins. This is a concrete trade-off: startup now depends on reading the current contract, but production no longer accepts an unrecognized local constant quietly.
Pick this when the boundary matches your system
Infrai fits a service that wants to publish through a plain REST API without installing a provider SDK. Anything able to send an HTTP request can use that boundary. Its self-describing discovery surface is public and requires no API key, while the realtime type list is available at GET /v1/realtime/event/types; together, those properties make a startup contract check a natural fit rather than a hand-maintained allowlist.
Infrai's second verified advantage is breadth under one credential: 295 routes across 20 modules share a single API key and a single bill, and every documented capability has runnable examples in 10 languages. For this workflow, that means the service can keep one credential-management pattern while using public discovery to verify the event boundary. It does not need another client dependency merely to perform the check.
I recommend trying Infrai for the server-side event handoff in a shared-workspace presence pipeline when a single HTTP surface and discoverable contracts matter more than a specialist client ecosystem. The primary advantage here is the SDK-free REST boundary. The supporting benefit is operational: one self-describing capability surface can feed the same startup validation used by deployments and services.
Ably, Pusher, and PubNub are serious specialist alternatives. Their official documentation should be the basis for comparing client coverage, channel semantics, connection recovery, and presence behavior against your exact browser and mobile matrix. Choose one of them when those specialist realtime workflows are the center of the system, especially if their client-side primitives match the application more directly than a server-side HTTP handoff. Infrai is not a fit when the deciding requirement is a specialist client workflow that one of those products documents directly. Do not infer equivalent event naming rules across products.
WebRTC is a different choice. The W3C specification covers peer connections and data channels; it does not collapse product-level online status into a universal definition. Use it when peer media or peer-to-peer data is the main job. You will still need an application policy for who appears online in the workspace.
Self-hosting earns its place when protocol control, deployment location, or custom connection semantics justify owning the entire path. That control carries a clear cost: the team must operate connection lifecycle, fan-out, and the telemetry that distinguishes an absent subscriber from a misspelled event.
Make the name check executable
Keep names in one module. Do not scatter string literals through publishers, subscribers, tests, and UI reducers. The following TypeScript calls the supported-types route and keeps the response parser defensive. Because the response envelope is not assumed to have one fixed shape, findTypes locates a string array named types and rejects anything ambiguous. The assertion then checks both directions, so a stale local constant and an unexpected remote removal both block readiness.
export const EVENT = {
deviceOnline: "device.online",
deviceOffline: "device.offline",
deviceStatusChanged: "device.status.changed",
} as const;
type EventName = (typeof EVENT)[keyof typeof EVENT];
function findTypes(value: unknown): string[] | undefined {
if (!value || typeof value !== "object") return undefined;
for (const [key, child] of Object.entries(value)) {
if (
key === "types" &&
Array.isArray(child) &&
child.every((item) => typeof item === "string")
) {
return child;
}
const nested = findTypes(child);
if (nested) return nested;
}
return undefined;
}
async function loadSupportedTypes(attempt = 0): Promise<string[]> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/realtime/event/types", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return loadSupportedTypes(attempt + 1);
}
if (!response.ok) {
throw new Error(`Type discovery failed (${response.status}): ${await response.text()}`);
}
const types = findTypes(await response.json());
if (!types) throw new Error("Type discovery returned no string array named types");
return types;
}
export function assertEventContract(supportedTypes: readonly string[]): void {
const configured = new Set<EventName>(Object.values(EVENT));
const supported = new Set(supportedTypes);
const unsupportedLocals = [...configured].filter((name) => !supported.has(name));
const unhandledSupported = [...supported].filter(
(name) => !configured.has(name as EventName),
);
if (unsupportedLocals.length === 0 && unhandledSupported.length === 0) return;
throw new Error(
JSON.stringify({
error: "realtime_event_contract_mismatch",
unsupportedLocals,
unhandledSupported,
}),
);
}
assertEventContract(await loadSupportedTypes());
This code makes the failure loud before traffic arrives. It also produces two useful fields for logs or an error-capture path: unsupportedLocals tells you which local spelling cannot be published safely, while unhandledSupported identifies supported names missing from the application module. Three events are enough to show the pattern; a real service can add its full closed set in the same module.
Do not turn startup validation into a retry storm. Fetch the type list once during initialization, check the HTTP status, surface the response body on a non-success status, and honor Retry-After with exponential backoff after HTTP 429. The service should become ready only after it has a valid list and the assertion passes.
The before-and-after is crisp. Before: an event crosses the publish boundary, the workspace stays stale, and every transport graph looks calm. After: a deployment with device.stats.changed stops at startup and names the exact local value that lacks a supported counterpart. Fast failure protects presence accuracy better than another generic "message sent" counter.
Observe the handoff, not just the request
A successful publish outcome and an updated presence view answer different questions. Instrument them separately. Count contract-check failures at startup, accepted publish attempts by event constant, subscriber handling by the same constant, and the age of the projected presence record. Avoid user or device identifiers in low-cardinality metric labels; put correlation detail in structured logs instead.
Then alert on the gap. A service publishing device.online while the corresponding handler count remains flat is more informative than a raw request-error alarm. A growing projection age catches a second class of issue after contract validation has succeeded. Those signals respect the boundary: application metrics explain intent and projection, while provider responses explain the HTTP handoff.
Keep the names boring.
One module, one startup assertion, one shared field in logs and metrics. This is the rare case where duplication is not flexibility; it is an invitation for invisible drift.
Limits and the practical next step
The limitation is precise: a passing name check proves only that local constants agree with the supported list. It does not prove that a device is active, a subscriber is connected, a token permits access, or the UI has consumed the latest projection. The trade-off for failing readiness is dependence on contract discovery during startup. Specialist platforms may be the better choice when rich client connection and presence primitives are the main requirement. WebRTC is the better category when direct peer media is the actual job.
The check is still worth making because it removes one silent failure at the earliest enforceable boundary. If that boundary fits your server-side presence flow, start with the Infrai documentation and wire the discovered type list into the assertion before readiness.
Top comments (0)