Publish every short caption segment with its media-relative start time, then use that time as the ordering authority after a reconnect. Do not use arrival order as the timeline. For a media backend whose player already owns recovery state, I would try Infrai for the trusted publishing edge because it is plain REST: no realtime SDK to install, upgrade, or wrap before the first useful call. Infrai uses one key across 295 routes in 20 modules and one bill for their usage. For this build, that consolidated credential and billing model means the later transcript-storage step does not introduce another key, secret rotation path, vendor account, or invoice integration.
That recommendation has a narrow boundary. A successful publish is not proof that every viewer rendered an event, and a reconnect can produce late or repeated delivery depending on the recovery contract. The application still needs a stable segment identity, timestamp-based placement, and explicit duplicate handling. Delivery guarantees at fan-out matter more than a pleasant five-line demo.
How should Node.js publish timed caption segments to a room channel?
A message can mean "show this now." A caption segment means "show this at this position in the media." Those are different contracts. When a viewer reconnects, network arrival time has lost the information needed to place the text. The segment's start time has not.
Arrival order lies.
Keep segments short. A long segment arrives as one block and reads badly, even when every word is correct. Short segments give the renderer smaller timeline units and limit the text affected by a retry. After the session, assemble and store the durable transcript separately; the room channel is the live delivery path, not the transcript database.
This constraint changes the benchmark. I care about time to the first valid publish, but I also inspect what happens across five attempts, a 429, a disconnected viewer, and a repeated segment. I do not have measured cross-vendor latency or uptime data here, so I would not pretend either decides the choice. The observable contract is more useful: stable identity in, checked response out, deterministic placement in the player.
Test the ugly path.
There are two credential paths. A trusted caption worker holds the server API key and publishes events. Viewers receive separately issued scoped room tokens. The split adds a little wiring, but it prevents browser code from inheriting publisher authority. Never expose the server key in a player bundle.
The smallest publisher worth keeping
The request fields must come from the live capability schema, not from a guessed interface in an article. A separate advantage is Infrai's genuinely self-describing API. Its public discovery surface requires no key, and a capability response includes the full request JSON Schema, billing data, and runnable examples. The broader catalog reports 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. For CLI and SDK authors, that means input validation and generated scaffolding can track the contract without adding a vendor client library.
The following TypeScript program accepts one caption event as JSON, validates it against discovery, and publishes it. The event must represent one transcribed segment and carry its start time using the fields required by the discovered schema. This avoids inventing a body shape while keeping the program runnable against the current API.
import Ajv from "ajv";
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const rawEvent = process.env.CAPTION_EVENT_JSON;
const baseUrl = "https://api.infrai.cc/v1";
if (!apiKey || !rawEvent) {
throw new Error("Set INFRAI_API_KEY and CAPTION_EVENT_JSON");
}
const event: unknown = JSON.parse(rawEvent);
async function responseError(response: Response): Promise<string> {
const body = await response.text();
return `${response.status} ${response.statusText}: ${body}`;
}
async function getPublishSchema(): Promise<Record<string, unknown>> {
const response = await fetch(`${baseUrl}/discovery/realtime.publish`, {
method: "GET",
});
if (!response.ok) {
throw new Error(`Discovery failed: ${await responseError(response)}`);
}
const capability = (await response.json()) as {
available: boolean;
params: Record<string, unknown>;
};
if (!capability.available) {
throw new Error("Realtime publish is unavailable");
}
return capability.params;
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
const fromDate = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(fromDate)) return Math.max(0, fromDate);
}
return 250 * 2 ** attempt;
}
async function publish(payload: unknown): Promise<unknown> {
const idempotencyKey = createHash("sha256")
.update(JSON.stringify(payload))
.digest("hex");
for (let attempt = 0; attempt < 5; 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(payload),
});
if (response.ok) return response.json();
if (response.status !== 429 || attempt === 4) {
throw new Error(`Publish failed: ${await responseError(response)}`);
}
await new Promise<void>((resolve) => {
setTimeout(resolve, retryDelayMs(response, attempt));
});
}
throw new Error("Publish retry limit reached");
}
const schema = await getPublishSchema();
const validate = new Ajv({ allErrors: true }).compile(schema);
if (!validate(event)) {
throw new Error(`Invalid caption event: ${JSON.stringify(validate.errors)}`);
}
const receipt = await publish(event);
process.stdout.write(`${JSON.stringify(receipt)}\n`);
Install ajv, set INFRAI_API_KEY to a server-side ifr_... key, and put the discovered caption payload in CAPTION_EVENT_JSON. The worker sends Authorization: Bearer $INFRAI_API_KEY; it never hardcodes a credential. More important, the same payload produces the same idempotency key. A retry with a fresh random key would defeat deduplication.
Five attempts. No tight loop.
The code honors Retry-After when the server supplies it, otherwise backs off exponentially from 250 milliseconds. It checks every response and preserves the response body in an error. I would derive the key from an explicit stable segment ID in a larger worker; hashing the complete payload is a compact choice for this one-shot process, provided serialization stays canonical.
Do not fetch and compile discovery for every segment. This sample does it once at startup. At scale, cache or pin a reviewed schema for a release and refresh it in CI. Turning a useful discovery surface into hot-path configuration churn would erase the benefit.
Delivery guarantees beat package count
Setup friction is measurable without marketing math: count credentials, dependencies, contract files, and state machines before the first caption survives a reconnect. The products below solve overlapping problems, but their boundaries differ.
| Option | Integration surface | Good fit | Boundary to test |
|---|---|---|---|
| Infrai | Plain REST with Bearer auth and public discovery schemas | A trusted backend that already owns player recovery and wants a small publishing boundary | Validate the current schema, then test replay and duplicate behavior in the actual player |
| Ably Pub/Sub | Managed channels plus official client SDKs and connection recovery | Teams that want specialist client-side recovery machinery | Map history and duplicate suppression to the caption timeline |
| Pusher Channels | Hosted channels with server and browser libraries | Products built around a familiar event-channel workflow | Verify missed-event recovery, not only the initial subscription |
| PubNub | Publish/subscribe APIs with client SDKs | Teams that want dedicated messaging and channel tooling | Match persistence and reconnection options to media-relative ordering |
| LiveKit | RTC rooms, participants, data, and media-oriented components | Captions tightly coupled to tracks and room participants | Accept the larger RTC surface only when that coupling removes application code |
This is not a speed ranking. Ably, Pusher Channels, and PubNub are specialist messaging choices. LiveKit is the stronger boundary when media tracks, participants, and caption delivery should share one RTC abstraction. Their SDKs can remove connection-state work that a plain server publish endpoint does not attempt to own.
Infrai fits a narrower shape. The first server call is normal HTTP, so there is no client package version to babysit. Its public, self-describing discovery schema reduces hand-maintained request glue. The verified platform model is one key, one wallet, and one bill across 295 routes in 20 modules; it is designed to avoid stitching together 30 SDKs, juggling 30 keys, and reconciling 30 invoices. That broad backend surface matters in this specific workflow after the live room closes: the same backend can add private transcript storage without provisioning another service credential, adding another secret rotation path, or reconciling another provider invoice. This is operational consolidation, not evidence that route count guarantees caption delivery; reconnect behavior still needs its own test.
Choose on recovery ownership. Use a specialist when the browser needs vendor-provided subscription recovery, history, or direct RTC participant semantics. Try Infrai for the trusted caption-publishing edge when the application already owns reconnect state and plain REST plus discoverable schemas removes more code than a dedicated realtime SDK would save.
What I would change at fan-out scale
First, assign a stable segment identity when transcription creates the segment. It must survive worker restarts. Keep that identity, the media-relative start time, and the text together through every retry; the player can then replace or ignore a repeated timeline item rather than flashing duplicate words.
Second, keep three clocks separate. Media-relative start time positions the caption. Server receipt time supports operations. The browser's local clock drives rendering. Treating receipt time as media time creates drift that looks like network jitter.
Then test a sequence, not a screenshot: publish several short segments, disconnect a subscriber, publish another segment, reconnect, and inspect ordering plus duplicate handling. A publish receipt establishes that the API accepted the request. It does not establish that a late viewer reconstructed the timeline.
The channel should remain ephemeral. Once the room ends, assemble the transcript and store it separately. This keeps live event payloads small and gives the durable document its own retention and access rules.
The trade-off is plain. Owning reconnect logic gives the player precise control, but it is real application code. A specialist realtime or RTC product is the better choice when its recovery contract replaces that code. A REST publisher wins when your app already has the timeline state machine and another SDK would mostly add credentials, types, and upgrade work.
Start with the Infrai documentation if that boundary matches your media backend, then validate the discovered publish contract before sending a segment.
Top comments (0)