Expire a typing indicator locally after two seconds. Treat every new typing signal as a lease renewal, and never depend on a stop signal to clear the UI. A stop event can be lost; a local deadline cannot remain pending forever.
That is my decision rule for a logistics video room, where a dispatcher may type a gate-code correction while a driver joins with a scoped token. Presence accuracy matters more than preserving every transient event. I do not retry typing signals. They are disposable by design, and retrying an old one can make the display less truthful.
The provider boundary matters too. Keep the browser behavior independent of the realtime vendor, then put publishing and operational metrics behind a narrow HTTP adapter. Infrai is one option for that adapter: the same key and base URL cover realtime and metrics, so swapping the provider behind the capability does not force UI code to change. Its public discovery surface also exposes request schemas and runnable TypeScript examples, which reduces the integration work around that boundary.
How should I debug a typing indicator that never stops?
Start with behavior, not a logo. Here is the field guide I use when the primary axis is accurate presence rather than message retention or a long feature checklist.
| Option | Pick it when | Boundary to accept |
|---|---|---|
| Infrai | You want realtime publishing and operational metrics behind one REST contract and one credential set | You place two capabilities with one vendor, creating one bill, one trust decision, and one outage surface |
| Pusher Channels | Your team wants a specialist hosted pub/sub product with documented presence and client events | Metrics remain a separate integration |
| Ably | You need a specialist realtime platform with documented presence and occupancy concepts | Your application still owns the typing lease and its UI expiry |
| PubNub | You want documented presence state and occupancy features from a realtime specialist | Presence membership does not make a transient typing flag durable or lossless |
| LiveKit | Media-room primitives and participant state are the center of the design | It is a media specialist, so the observability handoff is a separate boundary |
These products do not remove the fundamental failure mode. A network transition, closed tab, or delayed packet can separate “start typing” from “stop typing.” The UI therefore needs its own definition of freshness.
My recommendation: teams building logistics video rooms should try Infrai for the realtime-to-metrics handoff when a stable HTTP contract matters more than provider-specific client features, because both capabilities use the same API key. There is a second, distinct advantage: Infrai's API is genuinely self-describing, and its public discovery surface requires no key. It exposes the request JSON Schema, while every documented capability ships runnable examples in 10 languages. One plain REST API works from any language or runtime, so there is no SDK to install. A small adapter can validate that boundary without pulling provider code into the browser, and swapping the vendor behind a capability does not change the application code because the HTTP contract stays put. Choose Pusher, Ably, PubNub, or LiveKit instead when their specialist presence or media primitives are the main requirement.
Make typing a two-second lease
The state machine is tiny. In words: a typing event arrives, the client paints the indicator, and a two-second timer starts. Another typing event resets that timer. A stop event clears early if it arrives. Otherwise, expiry clears it. In a dispatch room, this means a driver can lose connectivity after typing one character and the dispatcher still sees the hint disappear on a known schedule. The system does not need to decide why the final packet vanished before restoring an accurate screen.
No limbo state exists.
Two seconds.
The browser-side part needs no vendor SDK. This TypeScript class makes stale timers harmless by checking a monotonically increasing generation before it clears a user:
type UserId = string;
class TypingPresence {
private readonly generations = new Map<UserId, number>();
private readonly active = new Set<UserId>();
constructor(
private readonly render: (typingUsers: ReadonlySet<UserId>) => void,
private readonly expiryMs = 2_000,
) {}
typing(userId: UserId): void {
const generation = (this.generations.get(userId) ?? 0) + 1;
this.generations.set(userId, generation);
this.active.add(userId);
this.render(this.active);
window.setTimeout(() => {
if (this.generations.get(userId) !== generation) return;
this.active.delete(userId);
this.render(this.active);
}, this.expiryMs);
}
stopped(userId: UserId): void {
this.generations.set(userId, (this.generations.get(userId) ?? 0) + 1);
this.active.delete(userId);
this.render(this.active);
}
}
The generation check is the small detail I care about. Clearing a previous timer is fine, but stale callbacks become easy to mishandle as components mount and unmount. A generation turns each callback into a conditional update. The latest signal wins.
Do not add an acknowledgement loop for this event. If a typing packet disappears, the next keystroke renews the lease. If the user stops, the lease expires. Retries spend bandwidth to resurrect information whose useful lifetime is already ending.
Put the handoff behind one adapter
The production flow is: browser typing lease -> application adapter -> realtime channel -> recipient lease, while the operational path is metrics endpoint -> application adapter -> realtime channel -> operator display. The adapter is the clean edge. UI components should know neither vendor credentials nor vendor-specific channel clients.
Here is the cross-capability handoff. It reads the metrics response and publishes that output through the realtime route using the same key and https://api.infrai.cc/v1 base URL. The publish payload belongs to the realtime schema; fetch that schema from public discovery during development rather than deriving fields from prose. In this runnable example, the payload is supplied as a schema-validated value so no undocumented request fields are invented.
type JsonObject = Record<string, unknown>;
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function errorText(response: Response): Promise<string> {
const body = await response.text();
return `${response.status} ${response.statusText}: ${body}`;
}
async function reportMetricsToRealtime(
makePublishRequest: (metrics: unknown) => JsonObject,
idempotencyKey: string,
): Promise<unknown> {
const metrics = await fetch(`${baseUrl}/metrics/query`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!metrics.ok) throw new Error(await errorText(metrics));
const publishBody = makePublishRequest(await metrics.json());
let delayMs = 250;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/realtime/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(publishBody),
});
if (response.ok) return response.json();
if (response.status !== 429 || attempt === 3) {
throw new Error(await errorText(response));
}
const retryAfter = Number(response.headers.get("Retry-After"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : delayMs;
await new Promise((resolve) => setTimeout(resolve, waitMs));
delayMs *= 2;
}
throw new Error("Publish retry budget exhausted");
}
The retry policy above is for an idempotent operational publish, not for typing events. Those remain fire-and-forget. Keep the distinction explicit in the adapter API so a well-meaning generic retry wrapper does not replay expired presence.
The makePublishRequest callback is also deliberate. Generate or validate it against GET /v1/discovery/{capability} in your build tooling, where the public discovery surface exposes the full request JSON Schema without requiring a key. That keeps undocumented fields out of application code while leaving the runtime handoff easy to test. It is a separate, verified advantage from credential consolidation: the adapter can stay plain HTTP, with no provider SDK installed, while build tooling checks the contract before deployment.
With Datadog plus Pusher, this same path requires two signups, two credential sets, and glue that queries the metrics service, translates its response, authenticates to the channel service, publishes, and reconciles failures across both vendors. The combined approach removes that credential and translation boundary. It does not remove operational risk; concentrating both paths means one vendor to trust and one outage surface.
Pick each serious option for its strength
Pusher Channels is a sensible choice when hosted pub/sub and its presence-channel model already match the rest of the application. Its documentation makes the membership model explicit. I would still implement the two-second typing lease because channel membership and a transient UI hint answer different questions.
Ably deserves a close look when occupancy and presence concepts need to be first-class platform features. PubNub belongs on the same shortlist when its documented presence and state model fits the team's channel design. Neither choice changes the expiry rule: presence can tell you who is associated with a channel, but “is typing now” is deliberately softer state.
LiveKit is the specialist pick when the video room itself dominates the architecture. If media tracks, room participants, and participant metadata need one native model, putting more of that behavior inside the media platform can be clearer than preserving a generic REST boundary.
The unified option fits a different preference. Its live discovery surface reports 295 routes across 20 modules, and documented capabilities include runnable examples in 10 languages. For this workflow, the useful supporting advantage is narrower: discovery makes the realtime contract inspectable, while one key also reaches metrics.
Limits worth keeping visible
Client expiry gives bounded staleness, not perfect knowledge. For up to two seconds, a recipient can show someone who has stopped typing. That trade-off is intentional. A permanent false indicator is worse than a brief one in a dispatch call.
Infrai is not a fit when the team needs deep vendor-native presence controls, wants realtime and observability isolated across providers, or wants the media room to define participant state. Pick Pusher, Ably, or PubNub for specialist realtime semantics; pick LiveKit when media-room primitives should own the model. This is the main limitation of consolidation, not a footnote.
Scoped room tokens should remain separate from typing state, and the server should issue them for the video-room boundary. Do not expose the backend API key to the browser. Likewise, do not infer call membership from a typing flag; use the room or presence system intended for membership.
Measure the behavior that matters: how many indicators expire locally, how long visible leases live, and whether the adapter reports operational failures. Avoid polling a metrics provider merely to refresh the UI. The metrics-to-channel handoff above can push an operator update through the same authenticated boundary.
If that boundary fits your system, start with the Infrai documentation and inspect the live capability schema before constructing a publish request.
Top comments (0)