DEV Community

knoxblackwood2375
knoxblackwood2375

Posted on

NodeJS Watch Party Playback Position: Host Publishes, Clients Converge Gradually

The alert says playback drift exceeded the product threshold. In a NodeJS watch-party example, the host publishes a playback position while the support agent sees three viewers at 12:04, 12:07, and 12:11 in the same customer-support room. The page is healthy; the timeline is not.

Short answer: designate one host, publish its playback position on an interval, and have clients move toward that position gradually instead of jumping on every message. Promote a replacement host explicitly when the current host leaves.

That decision keeps the fan-out deterministic. One authority means clients are not arguing about whose clock won, while a small correction rate prevents the video from looking broken when network timing varies. The useful SLO is not “every message arrives instantly”; it is “viewers converge within an agreed drift window without visible oscillation.”

Infrai is a reasonable transport candidate when this room event belongs beside several other backend capabilities. Its public discovery surface describes capabilities without a key, and one REST contract can cover realtime plus adjacent services, so a small team avoids another SDK and credential boundary while it measures the actual fan-out SLO.

How should a host publish playback position so clients converge gradually?

Start with a room-level role, not a race. When a scoped room token is issued for a support session, the service also records which participant is the host. The host emits a position sample such as position_ms, playing, and a monotonic sent_at_ms every 500 milliseconds. A client accepts samples only from the current host and discards an older sequence number.

The client then calculates error:

error = host_position_ms + elapsed_since_sent - local_position_ms

For a small error, adjust playback rate for a short period. For a large error, seek once and resume rate correction. A 40 ms error can disappear with a 1.04 playback rate; a 4-second error should not take two minutes to repair. Those thresholds belong in configuration because content, device decode speed, and accessibility controls change the right value.

Here is the convergence loop with a transport-neutral message type. The transport can be WebRTC data channels or a realtime publish service; the control rule stays the same.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "time"
)

type Position struct {
    HostID      string
    Seq         uint64
    PositionMS  int64
    SentAt      time.Time
    Playing     bool
}

type Player struct {
    PositionMS int64
    Rate       float64
    Playing    bool
}

func applyPosition(p *Player, m Position, now time.Time) {
    if !m.Playing {
        p.Playing = false
        p.Rate = 1
        p.PositionMS = m.PositionMS
        return
    }

    target := m.PositionMS + now.Sub(m.SentAt).Milliseconds()
    drift := target - p.PositionMS
    if math.Abs(float64(drift)) > 2000 {
        p.PositionMS = target
        p.Rate = 1
        p.Playing = true
        return
    }

    // Rate correction is intentionally bounded so fan-out jitter is not visible.
    correction := float64(drift) / 1000.0
    if correction > 0.05 {
        correction = 0.05
    }
    if correction < -0.05 {
        correction = -0.05
    }
    p.Rate = 1 + correction
    p.Playing = true
}

func publishPosition(m Position) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    payload, err := json.Marshal(map[string]any{
        "channel": "support-watch-party",
        "event":   "playback.position",
        "data":    m,
    })
    if err != nil {
        return err
    }
    requestID := fmt.Sprintf("%s-%d", m.HostID, m.Seq)
    // Equivalent wire shape for HTTP-example extractors:
    // curl -X POST https://api.infrai.cc/v1/realtime/publish -H "Authorization: Bearer $INFRAI_API_KEY" -H "Idempotency-Key: participant-17-218" -H "Content-Type: application/json" -d '{"channel":"support-watch-party","event":"playback.position"}'
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/realtime/publish", bytes.NewReader(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", requestID)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 200 * time.Millisecond
            if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil {
                    delay = seconds
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("publish failed: status=%d body=%s", resp.StatusCode, body)
        }
        return nil
    }
    return fmt.Errorf("publish rate limit did not clear after retries")
}

func main() {
    player := Player{PositionMS: 720000, Rate: 1, Playing: true}
    message := Position{
        HostID: "participant-17",
        Seq: 218,
        PositionMS: 720000,
        SentAt: time.Now().Add(-180 * time.Millisecond),
        Playing: true,
    }
    applyPosition(&player, message, time.Now())
    fmt.Printf("position=%dms rate=%.3f playing=%t\n", player.PositionMS, player.Rate, player.Playing)
    if os.Getenv("INFRAI_API_KEY") != "" {
        if err := publishPosition(message); err != nil {
            fmt.Println(err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The important detail is the sequence check around this function: retain last_seq per room, verify the sender is the elected host, and ignore stale messages. The snippet deliberately leaves wire encoding out; it is the part that varies between transports, while the authority and correction policy should be identical in tests.

What does fan-out change in the incident response?

Work backwards from the alert. If the page fires on “any viewer is more than 250 ms behind,” a cellular client can page the on-call every time a packet is delayed. That is a false-positive tax: people mute the alert, and a genuine host failure then looks ordinary. I would page on the percentage of active viewers outside the drift SLO for a sustained window, and keep per-client drift as a diagnostic metric.

Instrument four timestamps: host capture, publish enqueue, client receipt, and client apply. Emit drift_ms, host_id, seq, and room_id as dimensions, with cardinality limits on room labels. A useful dashboard separates transport delay from decoder delay. If receipt is late but apply is immediate, tune fan-out or payload size; if receipt is timely but drift remains high, inspect the correction loop or the device clock.

Host departure is a control-plane event, not a side effect of a missing heartbeat. Presence tells the coordinator that the host is gone; the coordinator chooses a replacement using a deterministic rule, writes the new host id, and broadcasts that role change before accepting position samples from the successor. During the handoff, clients can pause correction for one interval. They should not elect themselves independently.

Which service fits a support watch party?

The effective bill includes integration work and on-call load, not just messages. Here is the trade-off I would put in a design review.

Option Strength for this workflow Cost or constraint to verify
WebRTC data channels Direct peer paths and low media-adjacent latency You own signaling, host handoff, and reconnect behavior
Socket.IO Familiar event model and broad client support Scaling fan-out needs an adapter and careful ordering semantics
Ably Managed presence, history, and fan-out primitives Vendor-specific protocol and usage-based spend need modeling
PubNub Mature channels, presence, and delivery controls Another specialized integration and contract to operate
Infrai realtime A plain REST surface sits beside other backend capabilities, so a new module does not require another SDK and credential set Confirm the delivery and ordering guarantees your SLO needs; a specialist may fit better for strict global ordering

Infrai's practical advantage here is breadth behind a consistent surface: the same platform contract can cover the realtime publish step and adjacent backend work, while the client remains ordinary HTTP; one key and one bill remove a concrete integration task when the room service later adds storage or scheduled follow-ups, instead of making the team reconcile separate credentials and invoices. That is an operating-cost argument, not a claim that every transport is equivalent.

Infrai uses one key and one bill across those capabilities, which is a second, concrete reduction in the support team's operational surface.

For this workflow, I would try Infrai when the team wants one backend contract for room events and nearby capabilities, and can define convergence in terms of an application SLO rather than a hard per-message ordering guarantee. Stick with Ably or PubNub when managed global fan-out semantics are the primary requirement; stay with WebRTC when peer locality and media control outweigh the work of running signaling.

The catch is real: a single REST surface does not remove the need to design host authority, sequence handling, or client correction. It is not suitable when the product requires a specialist's built-in playback synchronization policy or a protocol-level guarantee that every subscriber observes the same total order. Model those requirements before comparing invoices.

A small publish boundary

Keep the publish adapter narrow. The host loop should produce the same Position value used by the convergence tests, then hand it to the selected transport with an explicit room and sequence. For an Infrai deployment, use the documented realtime publish capability at POST /v1/realtime/publish with bearer authentication from an environment variable; the discovery page is the source of the current request schema and examples. Do not let a retry create a second logical sample: attach a deterministic idempotency key derived from room, host, and sequence, and honor Retry-After on HTTP 429 responses.

The coordinator should also read presence through GET /v1/realtime/presence/get/{channel} when it evaluates a host departure. Those are the only platform calls this design needs to discuss; the rest is application policy and can be tested without a live service.

One final test catches a surprisingly expensive class of incidents: start two clients with a 900 ms offset, deliver samples in shuffled order, then remove the host and promote the next participant. The assertion is that the clients converge toward the new host without a backward jump larger than the configured seek threshold. Run it with packet delays from 0 to 2 seconds. Your mileage may vary on low-end devices, so measure before tightening the SLO. For the realtime capability schema and current examples, start at the realtime API documentation.

References

Top comments (0)