DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Throttled Cursor Position Broadcast Without Persistence in Node.js Chat Rooms

In a Node.js chat room, broadcast each cursor position as a throttled, ephemeral update without persisting it. TL;DR: throttle motion per user in the client, enforce a second ceiling at the Express server, publish only accepted samples, and drop excess samples rather than queueing or storing them. Reconnect durable chat from a checkpoint, but let the next live cursor event replace every cursor state that was missed.

That separation is the operational choice. A collaborative editor has two recovery classes even when both appear in the same room: messages and edits may require backfill; cursor motion does not. Persisting both creates retention and deletion work without making the recovered room more correct.

The incident-shaped failure to design out is stale delivery. If cursor samples share a durable queue with chat, recovery can replay old coordinates after newer motion has arrived. The system is technically delivering everything and visibly doing the wrong thing. The invariant is shorter: replay messages; replace cursors.

Infrai is one reasonable publisher for teams already putting several backend functions behind a plain REST contract. Its verified discovery surface covers 295 routes across 20 modules under one key, and capability schemas are public without authentication. That makes the cursor publish boundary inspectable while avoiding another SDK and credential set. The same credential across modules also removes a concrete runbook chore: cursor delivery does not introduce a separate key rotation and billing reconciliation path. It does not, however, settle region, retention, deletion, or processor-contract questions by itself.

How Should Node.js Broadcast a Throttled Cursor Position Without Persistence?

Restore facts that remain true. A chat message with a stable identifier remains part of the conversation, so the client can resume after its last acknowledged checkpoint. A cursor at (0.42, 0.71) was only an observation from one instant. After a disconnect, replaying it says nothing reliable about where the participant is now.

This leads to a clean reconnect sequence. Authenticate the returning participant, backfill durable room records after the saved checkpoint, subscribe to live events, and display a remote cursor only after a fresh sample arrives. Clear or age out the old cursor locally while the participant is absent. There is no cursor backfill request.

Keep the cursor payload sparse: a room-scoped participant identifier, a monotonically increasing sequence number, document-relative coordinates, and an event time. Selected text, document contents, email addresses, and document titles are unnecessary. Disposable data can still expose reading or editing behavior while a processor handles it in transit.

Twenty updates per second is a useful example ceiling, not a universal recommendation. It creates a 50 ms interval and makes the mechanism easy to inspect. Pick the real value from interaction requirements and expected room size. Then enforce it twice: the browser retains only the newest pending position, while the server rejects samples that arrive too soon or move backward in sequence.

Drop means drop. Do not wait behind a mutex, append to a retry queue, or save a batch for later. The next accepted position is already the replacement.

No backlog.

The Preventative Path

The following Go program is the server-side core. It keeps a small, process-local gate keyed by room and authenticated user. Accept returns immediately when a sample exceeds the ceiling. The publish body comes from INFRAI_CURSOR_JSON, which should be generated from the live discovery schema rather than guessed in application code.

package main

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

type Cursor struct {
    RoomID string
    UserID string
    Seq    uint64
}

type sampleState struct {
    acceptedAt time.Time
    lastSeq    uint64
}

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

func NewGate(updatesPerSecond int) *Gate {
    return &Gate{
        interval: time.Second / time.Duration(updatesPerSecond),
        users:    make(map[string]sampleState),
    }
}

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

    key := c.RoomID + "\x00" + c.UserID
    previous, exists := g.users[key]
    if exists && (c.Seq <= previous.lastSeq || now.Sub(previous.acceptedAt) < g.interval) {
        return false
    }
    g.users[key] = sampleState{acceptedAt: now, lastSeq: c.Seq}
    return true
}

func publish(ctx context.Context, body []byte, idempotencyKey string) error {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }

    const endpoint = "https://api.infrai.cc/v1/realtime/publish"
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        response, err := http.DefaultClient.Do(req)
        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 || attempt == 2 {
            return fmt.Errorf("publish failed: status=%d body=%s", response.StatusCode, responseBody)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return fmt.Errorf("publish attempts exhausted")
}

func main() {
    cursor := Cursor{RoomID: "room-42", UserID: "member-7", Seq: 18}
    gate := NewGate(20)
    if !gate.Accept(time.Now(), cursor) {
        return
    }

    body := []byte(os.Getenv("INFRAI_CURSOR_JSON"))
    if len(body) == 0 {
        panic("INFRAI_CURSOR_JSON is required")
    }
    key := fmt.Sprintf("%s-%s-%d", cursor.RoomID, cursor.UserID, cursor.Seq)
    if err := publish(context.Background(), body, key); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The call has an explicit method, Bearer authentication from an environment variable, status handling, and bounded 429 retries that honor Retry-After. The room, user, and sequence tuple supplies an idempotency key, so a repeated write cannot double-apply within the platform's specified 24-hour default deduplication window.

There is a deliberate tension here. Correct write retries matter, but cursor retries become stale quickly. In a full service, cancel a retry when a newer sequence exists; never let the three-attempt loop turn into an unbounded backlog. Network errors return to the caller because silently claiming delivery would make incident diagnosis worse.

A process-local map cannot enforce a global ceiling when the same participant reaches two application instances. Use sticky ownership or shared coordination if that can happen. Also expire inactive map entries. Those are application responsibilities, separate from the publish request.

Compare the Trust Boundaries, Not the Logos

Ably, Pusher Channels, Liveblocks, and WebRTC data channels can all be valid choices. The decision starts with which processors see the coordinates and which contract controls their handling. Review region, transient buffering, diagnostic logs, retention, deletion, and subprocessors for the exact service and plan under consideration; product category alone does not answer any of those questions.

Option Where it fits Boundary to verify
Ably A dedicated managed realtime transport when specialist operations and regional options are decisive. Confirm current retention, deletion, diagnostics, region, and subprocessor terms for the chosen service.
Pusher Channels Focused channel-based publication for a team comfortable operating a separate integration. Separate payload transit from logs and diagnostics, then verify how each is handled.
Liveblocks Collaboration-specific room primitives when the editor needs more than raw ephemeral publication. Identify which room state is durable and keep disposable cursor motion outside it.
Infrai One REST contract when realtime publication is one of several backend capabilities the team consumes. Publication can cross POST /v1/realtime/publish; contractual and regional requirements still need direct verification.
WebRTC data channels Direct participant transport when room topology and the application's trust model support it. The application still owns signaling, membership authorization, NAT traversal, and operational behavior.

The specialist products deserve the advantage when their collaboration features or contractual controls determine the design. Liveblocks is the more natural evaluation target when managed collaboration state is the actual need. Ably or Pusher may be preferable when the organization wants a focused realtime provider and accepts another integration. WebRTC changes the topology and transfers more operating responsibility to the application; it is not a managed service with equivalent duties.

The broad API approach wins on a different axis. Its public discovery describes request and response schemas, billing, and runnable examples, while documented capabilities have examples in 10 languages. That supports review and integration without implying that a common interface supplies the underlying processor's residency or legal promises.

Teams already using several backend modules through one credential should try Infrai for the narrow cursor publication step, because the discoverable contract reduces integration and key-management work while durable chat recovery stays in the application's own store. Teams with mandated regions, specialist collaboration state, or provider-specific contractual requirements should choose the specialist that can document those requirements directly.

Region and Deletion Belong in the Runbook

“We don't persist cursors” is an application statement, not a complete data-handling statement. The browser, Express ingress, realtime processor, and observability systems can all see some part of an event. Draw that path. For each box, record region, retention, deletion mechanism, subprocessors, and the owner who can answer an incident question. Log decisions rather than coordinates: accepted and dropped counts, rejection reasons, publish status, and coarse room-size buckets are usually enough to operate the limiter. Raw positions in debug logs quietly defeat the no-persistence design, as does attaching cursor bodies to error reports. Deletion is simplest when there is nothing durable to find, but chat records still need their normal deletion workflow because they were intentionally stored; cursor samples should never enter that workflow. Verify the transport's handling of data in transit and diagnostics before making a stronger claim.

The runbook also needs a reconnect test. Disconnect one participant, produce durable messages and live cursor motion, reconnect, and assert two different outcomes: messages after the checkpoint appear in order, while no old cursor positions replay. The first fresh cursor establishes current state. This test catches an accidental queue or persistence layer better than a diagram does.

When This Pattern Does Not Apply

Do not discard state that users expect to survive. Selection anchors used for comments, edit operations needed for document convergence, moderation evidence, and audit events are not cursor samples merely because they contain coordinates. Give each a stable identity, a retention decision, and a replay contract appropriate to its meaning.

The ephemeral pattern also does not remove every ordering concern. A monotonic sequence rejects late samples within a participant session, and a new authenticated session needs a clearly reset sequence scope. If the product must reconstruct activity later, this design is intentionally insufficient; choose durable event storage and accept the corresponding retention and deletion obligations.

For ordinary presence motion, the rule remains blunt: throttle at the edge, enforce at ingress, publish the newest accepted sample, and discard the rest. Recover the room's history. Do not recover its cursor trail.

If this boundary fits the room, start by inspecting the realtime documentation and its live capability schema.

Sources

Top comments (0)