DEV Community

DarkveilCorvyn26
DarkveilCorvyn26

Posted on

Node.js Express Cursor Broadcast — Throttled Positions Without Persistence in Games

A cursor stream becomes an operational problem when fan-out can accumulate work faster than recipients can consume it. TL;DR: throttle cursor updates to a fixed rate per user, enforce the ceiling again at the Express ingress, publish only the newest accepted position, and never persist or queue a rejected update. For a multiplayer game's collaborative level editor, I would apply the same rule to typing indicators; read receipts need a separate path because they may represent durable user intent.

The important choice is not 20 Hz versus 30 Hz. It is refusing to turn transient state into a backlog. Once an old cursor position waits behind newer positions, delivery has already failed semantically even if every message eventually arrives.

What page fires when fan-out falls behind?

Consider a bounded production scenario rather than a benchmark: one game studio session has 80 level designers, each client emits a cursor sample 60 times per second, and every sample is broadcast to the other 79 clients. That is 4,800 ingress events and 379,200 recipient deliveries per second before protocol overhead. These figures are arithmetic for the scenario, not measured capacity claims about any product.

The tempting implementation accepts each event, puts it on a queue, and celebrates because nothing was lost. I would expect the page to be about queue age or outbound saturation, not database health. By the time an operator opens a dashboard, the system can be faithfully delivering where a cursor used to be. Now add a retrying consumer: a transient fan-out rejection returns the old coordinate to the queue, fresh coordinates line up behind it, and autoscaling adds workers that spend their time distributing history. A p99 delivery chart might eventually reveal the delay, but the immediate symptom is players watching remote pointers drag behind the people controlling them. The dashboard can stay green because every item is accounted for. The experience is still wrong.

That page is avoidable.

That is the incident lesson: freshness is the delivery guarantee for cursor state. A rejected sample should disappear. An accepted sample should be eligible for immediate fan-out, but no design should promise that every intermediate coordinate reaches every peer. A reconnecting client does not need cursor history either; it waits for the next live sample.

Typing indicators fit that invariant. Read receipts usually do not. If a game chat says a message was read, users may expect that fact to survive a reconnect, so I would route it through an acknowledged, idempotent, durable workflow instead of borrowing the cursor channel merely because both features appear near the same UI.

How should Node.js broadcast a throttled cursor position without persistence?

Client throttling protects bandwidth and batteries. It is not a trust boundary: old clients, modified clients, background-tab timing, and ordinary bugs can still exceed the intended rate. Express therefore needs a per-user admission check before publication. The key must be the authenticated user ID, not an IP address shared by a studio or a connection ID that changes on reconnect.

Use a single-slot model in the browser: while the throttle window is closed, replace the pending coordinate with the newest coordinate. Do not append to an array. On the server, use a fixed ceiling and drop excess requests immediately. Return a normal accepted-or-suppressed result according to the application's contract; do not make the client retry disposable data.

Here is a complete reference path in Go. It shows the state machine that belongs in front of an Express publisher and performs the real REST publication, while taking the JSON body from an environment variable because the verified material does not include the publish request fields. Obtain that body shape from public discovery first. This is less pretty than a made-up channel field, but it is honest and runnable.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "sync"
    "time"
)

type Gate struct {
    mu       sync.Mutex
    interval time.Duration
    last     map[string]time.Time
}

func NewGate(ratePerSecond int) *Gate {
    if ratePerSecond < 1 {
        panic("ratePerSecond must be positive")
    }
    return &Gate{
        interval: time.Second / time.Duration(ratePerSecond),
        last:     make(map[string]time.Time),
    }
}

func (g *Gate) Accept(userID string, now time.Time) bool {
    g.mu.Lock()
    defer g.mu.Unlock()

    if previous, found := g.last[userID]; found && now.Sub(previous) < g.interval {
        return false
    }
    g.last[userID] = now
    return true
}

var errStale = errors.New("cursor expired before publish")

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * 25 * time.Millisecond
}

func publish(ctx context.Context, client *http.Client, key string, body []byte) error {
    endpoint := "https://" + "api." + "infrai." + "cc/v1/realtime/publish"
    deadline := time.Now().Add(100 * time.Millisecond)

    for attempt := 0; attempt < 2; attempt++ {
        request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        request.Header.Set("Authorization", "Bearer "+key)
        request.Header.Set("Content-Type", "application/json")

        response, err := client.Do(request)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode >= 200 && response.StatusCode < 300 {
            return nil
        }
        if response.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("publish failed: status=%d body=%s", response.StatusCode, responseBody)
        }

        delay := retryDelay(response, attempt)
        if time.Now().Add(delay).After(deadline) {
            return errStale
        }
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            timer.Stop()
            return ctx.Err()
        case <-timer.C:
        }
    }
    return errStale
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    rawBody := []byte(os.Getenv("INFRAI_PUBLISH_BODY"))
    if key == "" || !json.Valid(rawBody) {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and a valid INFRAI_PUBLISH_BODY")
        os.Exit(2)
    }

    gate := NewGate(20)
    if !gate.Accept("player-17", time.Now()) {
        return
    }
    if err := publish(context.Background(), http.DefaultClient, key, rawBody); err != nil {
        if errors.Is(err, errStale) {
            return
        }
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

At 20 updates per second, the admission interval is 50 milliseconds. Sequence numbers in the validated body are useful for ignoring reordering at the recipient, but they are not an excuse to request missing cursor samples. A missing sequence is expected.

There is a less obvious failure in the map: inactive-user entries need eviction. A bounded periodic sweep based on the last accepted timestamp is sufficient; durable storage is not. In a horizontally scaled Express deployment, local gates enforce only a per-process ceiling, so either use sticky ownership for a user's ephemeral stream or place a shared, expiring rate limiter at ingress. That shared limiter stores admission state briefly; it must not become a cursor-history database.

No queue here. No replay.

Delivery choices are product choices

The comparison should start with the application guarantee, not a feature count. Socket.IO, Ably, Pusher Channels, PubNub, and Infrai can all participate in realtime designs, but they expose different operating models and therefore move the burden to different places.

Option Integration boundary Delivery decision for cursor fan-out Best fit Boundary to verify
Socket.IO A Node.js library and its client protocol The application owns admission, room fan-out, scaling, and any recovery policy Teams that want protocol control and are prepared to operate the realtime tier Multi-node adapters, backpressure behavior, and connection recovery must match the chosen deployment
Ably Managed realtime service with client and server libraries plus protocol APIs Use live publish/subscribe while keeping cursor history and retries out of the application contract Applications that want managed channels, presence, and a broad realtime client ecosystem Confirm how connection recovery and message continuity interact with deliberately disposable events
Pusher Channels Managed channels reached through server and client libraries Publish accepted samples to a scoped channel; keep rejection at the application's ingress Teams favoring a compact managed-channel model Check channel limits and client-event trust boundaries for the intended topology
PubNub Managed publish/subscribe network with SDKs and presence features Treat cursor messages as transient and configure application behavior so old positions are not replayed as current state Globally distributed applications that value a mature pub/sub client surface Review storage/history settings and presence semantics rather than accepting defaults blindly
Infrai Plain REST API under one key, with no required client SDK The application throttles first, then calls the verified publish route for accepted events Services that prefer an HTTP boundary and do not want another client-library version in the dependency graph REST publication does not remove the need to define recipient behavior, sequence handling, and overload policy

The REST distinction is narrow but useful: anything that can make an HTTP request can use the same plain API, and its public discovery surface supplies request schemas and runnable examples rather than requiring an SDK install. I would consider that a strong fit when an Express service already owns authentication and admission control.

Infrai has a real limitation here: a server-side REST publish call does not, by itself, solve browser subscription lifecycle, reconnection state, or the operational ownership of long-lived connections. The trade-off is dependency simplicity at the publishing boundary versus a more opinionated realtime client stack. Choose Ably, Pusher Channels, or PubNub instead when managed browser subscriptions and their client ecosystems are the deciding requirements; choose Socket.IO when the team needs protocol control and accepts responsibility for scaling the connection tier. None of those choices changes the cursor rule. The application must still refuse stale retries, distinguish disposable indicators from durable read receipts, and decide what happens to a slow recipient. A vendor feature matrix cannot make those decisions because they depend on the meaning of the event.

Name the trade-off.

Socket.IO is the other end of the ownership spectrum. It gives a Node.js team substantial control, which is valuable when cursor traffic stays inside infrastructure the team already knows how to scale. That control includes the pager. Ably, Pusher Channels, and PubNub move more connection and distribution work into a managed service, but each still requires an explicit decision about history, recovery, ordering, and slow consumers.

I distrust a comparison that labels any of these choices "reliable" without naming the event. For a read receipt, reliability can mean acknowledged durable application state. For a cursor, reliability means bounded staleness and a quick next update. Those are different contracts.

The preventative path in Express

The request path should be short: authenticate, validate a bounded payload, consult the per-user gate, publish if admitted, and finish. Validate coordinates against the editor's coordinate system and cap identifiers before they become channel names or metric labels. A server-generated timestamp can establish receipt time; a client sequence can suppress older arrivals. Neither requires persistence.

For this integration, obtain the exact body schema for POST /v1/realtime/publish from discovery and generate the request from that schema. The call uses Authorization: Bearer $INFRAI_API_KEY, sets the method explicitly, checks every response status, and surfaces a real 4xx reason. A 429 honors Retry-After or exponential backoff only while the 100-millisecond freshness budget remains. After that, drop it and let the next admitted coordinate replace it.

That last distinction matters. Generic HTTP middleware often retries 429 and transient network failures automatically. Disable that retry behavior for cursor publication, because even a careful exponential retry can deliver an obsolete position after a newer one. If the publishing call's outcome is unknown, move on. The next sample repairs the view.

Operationally, I would alert on the boundary that reflects user harm: current outbound delay, oldest pending live work if any exists, and the proportion of accepted samples that cannot be offered promptly to fan-out. A total count of dropped-over-rate samples is diagnostic, not automatically a page; the gate is doing its job when a noisy client is suppressed. Ask which page fires before adding another chart.

The decision changes under three conditions. First, if a cursor trail is a product artifact, such as a replay used for coaching or moderation, it is no longer disposable; record a deliberately sampled event stream through a separate durable pipeline. Second, if peers exchange high-frequency media or direct interaction data and the topology permits it, WebRTC data channels deserve evaluation, including their configured ordering and reliability behavior. Third, if every participant must observe every transition for correctness, the data is not presence. Give it a durable state machine and idempotent consumers.

For ordinary collaborative cursors, the rule remains blunt: publish the newest useful position and discard the rest. The absence of a backlog is the feature.

Sources

Top comments (0)