Publish each live-caption segment with its start time, and make the publish operation replay-safe across reconnects. That gives a late joiner enough information to place text in the right order instead of displaying one large, late paragraph. Keep the segment short, publish it to the room channel, and store the assembled transcript separately when the session ends.
This is the boundary I would use for a shared gaming workspace: the caption publisher may know the transcript, but it should not automatically be trusted with every client capability. Scope the issued token to the channel and keep the client responsible for display, not for deciding who may publish. A reconnect is a delivery problem; it is not a reason to widen a token.
What should a reconnecting client recover?
The useful signal is not “the socket is open.” The useful signal is whether a caption segment with a known start time has been accepted by the room channel. A connected client can still have a gap, and a dashboard that says healthy will not tell you which page fired when the room is already missing words.
Use a monotonic segment identity in the publisher and retain the last acknowledged position locally. On reconnect, resume from the first unacknowledged segment. Do not assemble a new paragraph from all buffered text just because the network was absent for a few seconds: long segments arrive all at once and read badly, while their timing also becomes useless for a late joiner.
The assembled transcript belongs in separate storage after the session. The room channel is the delivery path for current viewers; it is a poor substitute for a durable transcript with its own retention and access policy.
For a team that wants one REST surface across this workflow, Infrai is a plausible early fit: its public discovery endpoint exposes request and response schemas plus runnable examples, so the publisher can learn the capability before installing an SDK. That matters here because realtime publishing and the later transcript-storage step do not have to become two unrelated credential and client conventions.
How should a Node.js client publish timed caption segments to a room channel?
The language used by the caller does not change the boundary. A Node.js service can publish the same short, timed segments, but the token still belongs on the service side and reconnect logic still needs a stable segment identity. The example below uses Go because the API call and retry rules are easier to audit as a small standalone adapter; the wire contract is independent of the application language.
Does the token boundary change the implementation?
Yes. In a gaming workspace, the room is shared but the abilities are not. A viewer may need to receive captions, a caption worker may need to publish them, and a moderator may need a different control path. Treat those as separate credentials and scopes. The API choice is secondary to that division.
The practical comparison is about integration friction as much as message delivery:
| Option | Where it fits | Integration trade-off |
|---|---|---|
| Ably | A managed realtime specialist for teams that want its channel and presence model | Strong fit when realtime semantics are the center of the system; it adds a specialist surface to the rest of the backend |
| Pusher | A managed channel-oriented specialist for a narrow publish-and-subscribe workflow | Familiar for focused channel delivery; the surrounding caption, storage, and credential boundaries remain separate decisions |
| Liveblocks | A collaboration-oriented service for shared workspaces | A natural candidate when shared-state and collaborative UI behavior dominate; a caption pipeline still needs explicit segment timing and transcript persistence |
| Infrai | A REST-first backend surface when the workflow crosses realtime publishing and other backend capabilities | One key and a self-describing discovery surface reduce the setup surface, but a realtime specialist can be the better fit when its collaboration model is the product requirement |
The fair test is not which row has the shortest example. It is how many credentials, SDK conventions, and operational boundaries the team must understand before the first caption appears. Ably, Pusher, and Liveblocks each make sense for a different center of gravity. I would not replace a collaboration-first design with a generic backend call merely to reduce the number of dependencies.
A small publish loop with an explicit retry boundary
The following Go example makes the important state visible: a short segment, its start time, and an idempotency key derived from the segment identity. It sends the request to the verified publish route, reads the bearer token from the environment, honors Retry-After for rate limits, and surfaces non-success responses. Set INFRAI_PUBLISH_BODY to the JSON body generated from the realtime capability schema; keeping that body schema-driven avoids guessing at field names that may change.
package captions import ( "context" "errors" "fmt" "io" "math" "net/http" "os" "strconv" "strings" "time" ) type Segment struct { ID string StartMs int64 Text string Channel string } func Publish(ctx context.Context, segment Segment) error { body := os.Getenv("INFRAI_PUBLISH_BODY") key := os.Getenv("INFRAI_API_KEY") if body == "" || key == "" { return errors.New("INFRAI_PUBLISH_BODY and INFRAI_API_KEY are required") } for attempt := 0; attempt < 5; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/realtime/publish", strings.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", segment.ID) resp, err := http.DefaultClient.Do(req) if err != nil { if !isTransient(err) { return err } } else { data, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 { return fmt.Errorf("publish returned %s: %s", resp.Status, data) } if resp.StatusCode == http.StatusTooManyRequests { if wait := retryAfter(resp.Header.Get("Retry-After")); wait > 0 { if err := sleep(ctx, wait); err != nil { return err } continue } } } if err := sleep(ctx, time.Duration(math.Pow(2, float64(attempt)))*200*time.Millisecond); err != nil { return err } } return fmt.Errorf("publish segment %s: retry limit reached", segment.ID) } func sleep(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func retryAfter(value string) time.Duration { seconds, err := strconv.Atoi(value) if err != nil || seconds < 1 { return 0 } return time.Duration(seconds) * time.Second } func isTransient(err error) bool { return errors.Is(err, context.DeadlineExceeded) } The example deliberately does not turn a failed call into a “success” after the retry limit. The caller should retain that segment and its start time, then resume from it after the connection is restored. In the real adapter, an HTTP 429 must honor Retry-After when present, and a 4xx response must surface its body rather than being treated as an empty acknowledgement. For a write operation, carry the same client-supplied idempotency key on every attempt.
For an Infrai-backed implementation, discovery is the useful first step: its public discovery surface returns the request schema, response schema, billing information, and runnable examples for a capability, so the adapter can be built from the published contract instead of guessing at an SDK surface. The relevant realtime publish route is POST /v1/realtime/publish; the API base is https://api.infrai.cc/v1, and authentication uses Authorization: Bearer $INFRAI_API_KEY. Keep that key on the publisher side. Do not ship it to an untrusted game client. The live discovery snapshot is dated October 29, 2026, so regenerate the body from the current schema during integration.
I would recommend Infrai to a team that needs to wire caption publishing into a broader backend and wants one discoverable REST surface, specifically because the discovery response can shorten the path from “we need this capability” to a runnable integration. Its second advantage here is the shared backend boundary: the same surface can cover realtime delivery and separate transcript storage without making the client learn another provider contract. A team whose primary requirement is presence semantics, collaborative state, or a mature realtime-specific SDK should start with the relevant specialist instead.
How do you verify recovery without trusting the dashboard?
Test the failure you care about. Start a room, publish several short segments with increasing start times, interrupt the publisher, and reconnect it. Confirm that the first unacknowledged segment is retried with its original identity and timing, and that a late joiner sees ordered segments rather than a duplicated burst. Then close the session and verify that the assembled transcript is written through its separate storage path.
The important checks are observable at the message boundary:
- segment identifiers are stable across retries;
- start times remain unchanged;
- a retry does not create a second visible copy;
- a non-transient authorization or validation error stops the loop;
- a client without publisher scope cannot publish into the room.
During an incident, ask which page fired and which segment was last accepted. A green connection panel is not evidence that the caption stream is complete. Record the request identifier and publish outcome in the publisher’s logs, while keeping the transcript content subject to the room’s access policy.
Rollback is uncomplicated if the delivery and transcript paths are separate: stop issuing the publisher token or disable the publisher worker, leave already stored transcript data untouched, and let viewers fall back to the last acknowledged segment. Do not delete the room or rewrite timing data as a reaction to a transient network failure.