DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

Why I Chose 3 Server Throttle and Client Expiry Boundaries for Typing Indicators

TL;DR: Treat a typing indicator as an expiring hint, never as a message with durable delivery. Throttle repeated input on the server, attach a lifetime to every fan-out event, and let each client remove the indicator locally even if a stop event is lost. For B2B SaaS chat rooms that must survive reconnects, this produces the right failure behavior: messages recover; stale typing state disappears.

A page should fire when chat delivery is broken, not because a transient hint was dropped. That distinction drives the design. Typing signals need weaker guarantees than chat messages, because replaying an old typing=true event can make a room look active long after its sender has left.

I frame the incident before looking at a dashboard: a browser disconnects after sending typing=true; the server never observes a clean stop; another participant keeps the tab open; then the connection resumes. The stale label survives unless expiry belongs to the receiver, and replay makes the error last longer rather than repairing it. This yields one invariant. A typing signal must become harmless without a compensating event.

Drop it.

What should happen when the stop signal never arrives?

The receiving client should clear the indicator at a deadline derived from the latest accepted event. A typing=false event can clear the UI sooner, but correctness cannot depend on it. WebSocket supplies an ordered byte stream over one connection; a reconnect creates a new connection, and application code still decides which state is durable. The protocol does not define chat replay, typing semantics, or presence recovery.

Use three boundaries. First, the browser emits intent at a modest cadence while keys are moving. Second, the server accepts at most one refresh per user-room pair during its throttle window. Third, every accepted refresh expires at the client. Browser pacing reduces ingress, server throttling protects fan-out from buggy clients, and local expiry covers loss, disconnect, background suspension, and a missing stop.

Do not use a server wall-clock timestamp as a precise browser countdown unless clock synchronization is part of the contract. An event can carry ttl_ms, with the receiver computing expiresAt = localNow + ttl. The TTL describes freshness; it does not pretend two clocks agree.

Keep these signals out of the durable message log. On reconnect, resume messages from a cursor, rebuild room membership through authorization, and start typing state empty. Replaying a five-second hint after a twenty-second interruption is faithful delivery of the wrong truth.

How should a Node.js typing indicator combine server throttle and client expiry?

This Go core is independent of a router or socket package. A Node.js Express handler can enforce the same contract at ingress; the state machine is the important part. The throttle key includes both room and authenticated user. A room-only key lets one busy typist suppress everyone else, while a user-only key couples unrelated rooms. As a concrete starting policy rather than a universal constant, a team might test a 1-second server interval with a 5-second client TTL, then change both from observed typing cadence, background-tab behavior, and fan-out load; the TTL must remain comfortably longer than the refresh interval or harmless jitter will make the label flicker.

package typing

import (
    "sync"
    "time"
)

type Signal struct {
    RoomID string `json:"room_id"`
    UserID string `json:"user_id"`
    Typing bool   `json:"typing"`
    TTLMS  int64  `json:"ttl_ms"`
}

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

func NewGate(interval, ttl time.Duration) *Gate {
    return &Gate{last: make(map[string]time.Time), interval: interval, ttl: ttl}
}

func (g *Gate) Accept(roomID, userID string, typing bool, now time.Time) (Signal, bool) {
    key := roomID + "\x00" + userID
    g.mu.Lock()
    defer g.mu.Unlock()

    // Stops bypass the refresh throttle so receivers can clear early.
    if !typing {
        delete(g.last, key)
        return Signal{RoomID: roomID, UserID: userID}, true
    }
    if previous, ok := g.last[key]; ok && now.Sub(previous) < g.interval {
        return Signal{}, false
    }
    g.last[key] = now
    return Signal{RoomID: roomID, UserID: userID, Typing: true, TTLMS: g.ttl.Milliseconds()}, true
}
Enter fullscreen mode Exit fullscreen mode

Authenticate and authorize room membership before Accept; a client-supplied user ID is not identity. Validate event shape, cap payload size at the transport edge, and bound throttle entries. This in-memory map fits only when a connection's routing belongs to one process or requests are consistently partitioned. With independent ingress processes, use a shared decision or deterministic ownership, and document partition behavior. A local limiter that occasionally admits an extra refresh can be acceptable. Silently blocking every stop is not.

Fan-out sends an accepted signal only to connected, authorized room members, normally excluding the sender. There is no acknowledgement loop and no retry queue for it. That guarantee is deliberately weak.

At the receiver, retain one deadline per room-user pair. Replace it on a newer refresh and delete it immediately on typing=false. The transition is easy to test without sleeping:

package typing

import "time"

type Indicator struct { ExpiresAt time.Time }

func Apply(current map[string]Indicator, event Signal, now time.Time) {
    key := event.RoomID + "\x00" + event.UserID
    if !event.Typing || event.TTLMS <= 0 {
        delete(current, key)
        return
    }
    current[key] = Indicator{
        ExpiresAt: now.Add(time.Duration(event.TTLMS) * time.Millisecond),
    }
}

func Expire(current map[string]Indicator, now time.Time) {
    for key, value := range current {
        if !now.Before(value.ExpiresAt) { delete(current, key) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Browser code should rescan deadlines after visibility changes instead of trusting a timer to fire exactly while a tab is suspended. The deadline is truth; the timer merely requests a wake-up.

Delivery guarantees must differ at fan out

Putting messages and typing events through one generic publisher is convenient until retry policy crosses the boundary.

Property Chat message Typing signal
Meaning Durable user content Temporary UI hint
Reconnect Resume from an acknowledged cursor Start empty
Duplicate handling Stable ID and idempotent insert Latest deadline replaces prior state
Loss response Retry or recover from history Wait for refresh or expiry
Operational response Page on failed delivery or recovery Diagnose unless wider transport fails

If hints inherit durable retries, recovery traffic can rise while connections return, and users see obsolete activity. If messages inherit ephemeral behavior, acknowledged content can vanish. One transport may carry both classes, but its envelope should identify the class and consumers must apply separate policies.

WebRTC data channels can also carry application data, yet changing transport does not remove the distinction. The WebRTC specification defines data-channel behavior and configuration; the application still owns expiry, authorization, room membership, and replay policy. A broker has the same boundary: transport delivery terms do not create product semantics.

How do I test reconnects without trusting the clock?

I distrust a green dashboard if nobody can name the page that would fire. Expose the clock and fan-out sink, then assert transitions. No test needs a multi-second sleep.

Test two inputs inside the throttle interval, an immediate stop, a new start after stopping, two users in one room, one user in two rooms, expiry without a stop, a late stop, and reconnect with no typing replay. Then model active typists, room size, and refresh interval under load, because ingress and fan-out are different quantities.

If A users are typing, the server accepts one refresh per interval T, and each event has R recipients, steady fan-out is approximately A / T * R deliveries per second. It is an estimate. Stops, uneven rooms, reconnect bursts, and authorization failures alter the shape. Record accepted, throttled, expired, and rejected events separately; also measure connection churn and durable-message recovery, since falling typing traffic can mean successful throttling or a broken realtime path.

Page on user-visible durable-message failure, sustained inability to establish authorized room connections, or broad fan-out failure against a defined service objective. A high count of throttled typing refreshes is usually diagnostic. Alerting on every dropped hint teaches the on-call engineer to ignore the channel.

Deploy the envelope compatibly: readers ignore unknown fields, and writers do not change an existing field's meaning in place. Roll out receivers that understand TTL before senders rely on it. During rollback, an older receiver still needs a maximum display lifetime; otherwise compatibility becomes stale presence.

Where are the limitations and trade-offs?

This model is not suitable for sent messages, approvals, assignments, or audit records. That limitation is intentional: those records require identifiers, authorization, persistence, acknowledgements, and recovery tested as one path. The central trade-off is accepting missed typing hints so that reconnect cannot resurrect stale ones.

Typing indicators may also be inappropriate in regulated or sensitive rooms where activity metadata reveals more than the feature is worth. In very large rooms, individual names can become noise; a bounded aggregate hint or no indicator may fit better. Accessibility preferences should affect presentation while the expiry rule remains unchanged. There isn't one correct TTL for every interface, and hiding that choice behind a library default only makes the operational contract harder to inspect.

My decision rule is specific: use server-throttled, client-expiring signals when state is advisory, freshness matters more than completeness, and reconnect must discard it. If a consumer needs to prove later that the event occurred, move it to the durable path and accept the storage, idempotency, and recovery work. Never make the pager compensate for an undefined delivery contract.

References

Top comments (0)