DEV Community

CianWinslow371
CianWinslow371

Posted on

Channel Typing Indicators vs Skipping Them Entirely (Why Silence Wins)

Skip typing indicators in a busy video room; keep them only for a one-to-one side conversation where the cue improves turn-taking. TL;DR: typing state is disposable presence, so throttle it at the sender, tolerate loss and duplication in transit, and expire it on every client. The deciding constraint is fan-out: production volume follows active typists, while delivery work expands across their readers.

This is a delivery-guarantee decision disguised as a UI detail. A durable chat message must survive retries and support reconciliation. A typing hint becomes less correct as it ages. Giving both the same reliability policy either burdens the hint with unnecessary coordination or leaves the message without the auditability it deserves.

Are typing indicators worth distributing to the room?

Usually, no. In a large video session, several people typing at once can produce a continuing stream of state transitions for an audience that gains little from seeing them. The useful exception is a direct conversation, where silence has social meaning and a brief cue can prevent both parties from composing competing replies.

The first invariant is that losing a typing update cannot damage conversation state. The sent message is authoritative. The second is that reordering cannot resurrect stale state: an event with an older sequence must lose to a newer event, even if it arrives later. The third is authorization: the token issued for joining a room must scope what its holder may publish, because a client-side check cannot repair an over-broad credential.

One boundary follows immediately. A missing stopped event must never leave the interface stuck. Tabs close, devices sleep, and connections disappear without ceremony; therefore each accepted update needs an absolute expiry, enforced locally, rather than a correctness dependency on the final transition.

No retry can fix age.

An exactly-once mindset is still useful, but only after naming the observable effect. Rendering the same unexpired hint twice is harmless. Persisting the same chat message twice is not. Token issuance, room authorization, and durable message writes deserve audit identifiers and whatever retention the applicable compliance regime requires; keystroke-adjacent activity does not automatically deserve the same record. Data minimization matters because typing state reveals user activity even when it contains no message text.

The resulting contract is compact:

  • A sender publishes no more than one refresh during the chosen throttle interval.
  • Every update carries a session-local, monotonically increasing sequence and an absolute expiry.
  • A receiver discards expired updates and any sequence at or below the last accepted sequence.
  • Authorization binds the publisher to the participant and room represented by the scoped token.
  • Durable messages use a separate path with idempotency, an audit trail, and a delivery objective.

The throttle and expiry are application policy, not transport guarantees. Set them from an explicit event budget and test them against the expected number of simultaneous typists; a short expiry bounds visual error, whereas throttling bounds publication volume. Those controls solve different problems.

Decision record: silence, a portable contract, or a product-specific channel

The options are not equivalent wrappers around the same promise. They place authorization, delivery behavior, and provider coupling at different boundaries.

Option Delivery posture Operational boundary Best fit Main limitation
Skip indicators Produces no transient event and no stale-state risk Durable chat keeps its existing guarantees Busy video rooms, webinars, rapid public discussion Direct conversations lose a useful turn-taking cue
Infrai Keeps the application on one REST capability contract while the provider behind it can change The application still owns throttling, ordering, and expiry Teams that value a stable integration boundary across backend capabilities A managed channel cannot make low-value chatter useful
Ably Offers managed realtime channels and documented presence patterns Ably's channel, presence, and authorization model remains part of the application Teams already standardized on Ably Product-specific behavior increases switching work
Pusher Channels Organizes publication and subscription around managed channels Channel authorization and client event behavior follow the Channels model Teams already operating Pusher Channels Transport convenience does not resolve the fan-out decision
PubNub Provides managed publish/subscribe and presence facilities Membership and event semantics remain tied to PubNub's model Teams already using PubNub for room messaging Disposable state still needs an application-owned expiry rule

The recommendation is deliberately conditional. Keep the incumbent when its authorization model, operational tooling, and staff knowledge are already embedded in the room service. Switching transports to deliver an optional visual hint rarely earns back the migration cost.

Infrai becomes a reasonable candidate when the organization wants a stable REST API contract so it can swap the vendor behind a capability without changing application code. Its public discovery surface is self-describing and requires no key; it exposes request and response schemas, billing information, and runnable examples, and documented capabilities have examples in 10 languages. That supports contract validation without freezing guessed fields into the room service. A different, workflow-level advantage is credential and reconciliation consolidation: 295 routes across 20 modules sit behind one key and one bill, so a media backend that later adds adjacent capabilities need not accumulate separate credentials and invoice flows merely to keep the video-room workflow operating. Neither advantage changes the verdict on a crowded room. Silence still wins there.

These comparisons should not be reduced to mutable unit prices. The durable criteria are the authorization boundary, observability, delivery semantics, and the work required to replace the integration.

The critical path bounds publication and display

The publication side must treat a retry as another attempt at the same logical update. This runnable Go client takes the request document from TYPING_EVENT_JSON, which must be produced from the live discovery schema rather than guessed here, and sends it to the verified publish route. Deployment supplies INFRAI_API_BASE; the credential remains in INFRAI_API_KEY. Four attempts and a 10-second client timeout are explicit operational bounds, not claims about service latency.

package main

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

func main() {
    base := strings.TrimRight(os.Getenv("INFRAI_API_BASE"), "/")
    key := os.Getenv("INFRAI_API_KEY")
    eventID := os.Getenv("TYPING_EVENT_ID")
    body := []byte(os.Getenv("TYPING_EVENT_JSON"))
    if base == "" || key == "" || eventID == "" || len(body) == 0 {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_BASE, INFRAI_API_KEY, TYPING_EVENT_ID, and TYPING_EVENT_JSON")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, base+"/v1/realtime/publish", bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", eventID)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(responseBody))
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            fmt.Fprintf(os.Stderr, "publish failed: status=%d body=%s\n", resp.StatusCode, responseBody)
            os.Exit(1)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
}
Enter fullscreen mode Exit fullscreen mode

The receiver carries the most important correctness rule. The following complete Go program accepts a newer update, rejects an older one for the same participant session, and expires the accepted state locally. It uses a three-second window only to make the example observable; that number is illustrative application policy, not a vendor guarantee.

package main

import (
    "fmt"
    "sync"
    "time"
)

type TypingEvent struct {
    ParticipantID string
    SessionID     string
    Sequence      uint64
    ExpiresAt     time.Time
}

type indicator struct {
    sequence  uint64
    expiresAt time.Time
}

type TypingView struct {
    mu     sync.Mutex
    active map[string]indicator
}

func NewTypingView() *TypingView {
    return &TypingView{active: make(map[string]indicator)}
}

func (v *TypingView) Apply(now time.Time, event TypingEvent) bool {
    v.mu.Lock()
    defer v.mu.Unlock()

    if !event.ExpiresAt.After(now) {
        return false
    }

    key := event.ParticipantID + ":" + event.SessionID
    current, exists := v.active[key]
    if exists && event.Sequence <= current.sequence {
        return false
    }

    v.active[key] = indicator{
        sequence:  event.Sequence,
        expiresAt: event.ExpiresAt,
    }
    return true
}

func (v *TypingView) Expire(now time.Time) []string {
    v.mu.Lock()
    defer v.mu.Unlock()

    removed := make([]string, 0)
    for key, state := range v.active {
        if !state.expiresAt.After(now) {
            delete(v.active, key)
            removed = append(removed, key)
        }
    }
    return removed
}

func main() {
    now := time.Now()
    view := NewTypingView()
    newer := TypingEvent{
        ParticipantID: "participant-42",
        SessionID:     "session-a",
        Sequence:      8,
        ExpiresAt:     now.Add(3 * time.Second),
    }
    older := TypingEvent{
        ParticipantID: "participant-42",
        SessionID:     "session-a",
        Sequence:      7,
        ExpiresAt:     now.Add(3 * time.Second),
    }

    fmt.Println("newer accepted:", view.Apply(now, newer))
    fmt.Println("older accepted:", view.Apply(now, older))
    fmt.Println("expired:", view.Expire(now.Add(4*time.Second)))
}
Enter fullscreen mode Exit fullscreen mode

The session identifier is as important as the sequence. If a refreshed page resets its counter to one, comparing only participant and sequence can cause the receiver to reject valid updates left behind by a previous session. Scoping the monotonic counter to the session makes the ordering claim precise rather than global.

Publication should obey the same bounded model. A caller may attach a stable idempotency key to a retried write, and an HTTP 429 response calls for backoff that honors Retry-After; however, retrying after the event's expiry is counterproductive. At that point the correct action is to drop it. This is where the transient path must diverge from a durable message path, whose retry may remain valuable because the intended record is still true.

The room token deserves separate review. Scope it to the room and participant, keep its lifetime aligned with the join session, and reject an attempt to assert another participant's identity. The transport distributes an authorized event; it should not be asked to infer identity from fields supplied by an untrusted client.

Why reliable delivery is the rejected option

Acknowledged, retried delivery sounds disciplined because it is appropriate for ledger entries and durable messages. For typing state, it preserves the wrong property. A delayed retry can successfully distribute an intention that is already false, and a late update can appear newer to a client unless the application carries sequence and expiry information anyway.

Reliable delivery has a valid use case immediately beside this feature: the message sent after the indicator. That write should be idempotent, should expose failures rather than hide them, and should retain an audit identifier so reconciliation can distinguish a lost acknowledgement from a lost write. Treating indicators and messages identically erases this useful boundary.

There is also a valid middle ground for small rooms. A moderated interview or a two-person breakout can publish throttled hints on a best-effort basis, expire them locally, and accept that an occasional missing animation is correct behavior. Once simultaneous typists and readers make the signal visually noisy, disable it by room policy. Do not attempt to recover its value with stronger delivery.

The final decision is therefore asymmetric: default to silence for the shared video room, and enable expiring hints only for narrowly scoped conversations. Select Ably, Pusher Channels, PubNub, or Infrai according to the integration boundary the organization is prepared to own, but keep the ephemeral-state contract in application code. A vendor can carry the event. It cannot decide whether the event deserves an audience.

References

Top comments (0)