DEV Community

RaffertyBarrett4726
RaffertyBarrett4726

Posted on

Typing Indicators for Marketplace Devices: Is Channel Chatter Worth Keeping?

Use typing indicators only when they change an operator's next action, and keep them outside the delivery path for device status. For a marketplace operations dashboard, online, offline, degraded, and maintenance transitions deserve replay and deduplication. A hint that another operator is composing a note does not. If both compete in one ordered channel, optional chatter can delay the state the dashboard exists to show.

TL;DR: give status events durable identity and resumable delivery; give typing activity a short lease, aggressive coalescing, and permission to disappear. Under congestion, reconnect, or rollout trouble, disable the hint first. Silence is an acceptable result for typing activity. A stale device state is not.

Should typing indicators share a channel, or should we skip them?

The failure starts when a team treats every real-time message as equivalent. Imagine a marketplace with sellers' payment terminals, label printers, and pickup scanners on one live operations screen. A terminal changes from online to degraded while three operators are typing into the shared incident note. If the transport, broker subscription, or client queue preserves one global order, a burst of replaceable activity can sit ahead of the status transition.

The hint is transient by definition. A late typing=true event may arrive after the author stopped; a replayed one is worse than no hint because it presents expired intent as current activity. Status has the opposite shape. Operators need the newest known state, but reconnecting clients also need a defensible way to detect what they missed.

Skipping them entirely is worth considering when the dashboard has no shared editing workflow. There is no operational benefit to broadcasting intent that cannot prevent a collision or change an operator's choice.

This is the delivery-guarantee split:

Signal Identity Reconnect behavior Congestion policy
Device status Stable event ID plus per-device version Resume or fetch a snapshot, then apply newer events Preserve; shed optional work first
Typing activity Actor and room, with an expiry Do not replay expired activity Coalesce or drop

Do not promise exactly-once display. Networks retry, processes restart, and clients reconnect. The practical contract is at-least-once delivery for status combined with idempotent application, while activity is best effort. That contract can be tested.

Separate correctness from presence chatter

Use two logical streams even if one physical connection carries both. The status stream owns a cursor or snapshot boundary. The activity stream owns no durable backlog. Separate queue limits and scheduling weights matter more than separate socket URLs: independent URLs still fail together if they feed the same unbounded outbound queue.

A status event needs enough information for a client to reject duplicates and old writes. A monotonically increasing version scoped to one device is easier to reason about than arrival time; wall clocks can disagree, and arrival order changes across reconnects. The consumer applies version 43 once, ignores another 43, and refuses version 42. On a detected gap, it requests a fresh snapshot rather than guessing intermediate state.

Typing activity should act like a lease. active=true sets a local expiry, and another event renews it. The UI clears the indicator when the lease expires, so a missing stop event cannot leave a permanent label. The server should coalesce repeated renewals for the same room and actor. This is a deliberate loss of detail, not a reliability defect.

Here is the boundary I would put in the fan-out worker. The values are operating policy, not universal constants: this example uses a 3-second activity lease and refuses status regressions.

package fanout

import "time"

type Kind string

const (
    DeviceStatus Kind = "device.status"
    TypingLease Kind = "typing.lease"
)

type Event struct {
    Kind      Kind
    DeviceID  string
    RoomID    string
    ActorID   string
    Version   uint64
    State     string
    ExpiresAt time.Time
}

type ClientState struct {
    DeviceVersion map[string]uint64
}

func (s *ClientState) Accept(e Event, now time.Time) bool {
    switch e.Kind {
    case DeviceStatus:
        last := s.DeviceVersion[e.DeviceID]
        if e.Version <= last {
            return false
        }
        s.DeviceVersion[e.DeviceID] = e.Version
        return true
    case TypingLease:
        return e.ExpiresAt.After(now)
    default:
        return false
    }
}
Enter fullscreen mode Exit fullscreen mode

Keep persistence outside Accept: acknowledge a durable status only after the new version is stored or otherwise made recoverable. For activity, avoid an acknowledgment loop. Acknowledging every keystroke-shaped renewal turns a disposable UX hint into extra bidirectional load and retry state.

One physical transport can still express the split. WebRTC data channels, for example, expose ordered delivery and limited-retransmission choices in the standard. That makes a reliable ordered channel and a separate unordered, no-retry activity channel possible. It does not remove the need for application-level versions, expiry, queue bounds, or reconnect recovery. Those are properties of the message contract.

Put overload behavior in the runbook

The first alert should not be raw message count. Watch the age of the oldest unsent status event, status queue depth, reconnect recovery time, version-gap rate, and dropped activity renewals. A high activity-drop count can be normal during a busy incident; rising status age is the page.

Set explicit bounds. For example, allow at most one pending typing state per actor and room, replacing it when a renewal arrives. Give each client a bounded activity queue. When that queue fills, discard activity entries rather than borrowing capacity reserved for status. If the status queue reaches its own bound, stop pretending the client is healthy: disconnect it with a resumable cursor or force a snapshot refresh, according to the protocol contract.

Be careful with one tempting shortcut: prioritizing status inside a single FIFO after enqueue does nothing if activity has already consumed all queue memory. Admission control must happen before the shared resource is exhausted.

Short version: reserve correctness capacity.

Drop the hint.

Cost belongs in this runbook, but it is not the decision rule. Measure bytes, messages, wakeups, and fan-out work per active dashboard. Coalescing activity typically reduces all four, yet the operational reason to do it is isolation. A cheap optional event can still occupy a scarce queue slot at the wrong moment.

Verify the guarantees before rollout

Test the ugly sequence, not just the happy animation. Start a client at device version 40, deliver 41 twice, delay 42, deliver 43, and verify that the client detects the gap or obtains a snapshot according to the chosen contract. Then flood activity renewals, pause reads, reconnect, and confirm that expired leases never replay while status converges to the current snapshot.

The deployment check should include four observable assertions:

  1. Duplicate status events do not produce duplicate transitions or alerts.
  2. An older per-device version cannot overwrite a newer one.
  3. Activity load cannot increase the bounded capacity reserved for status.
  4. After a disconnect, the client reaches the authoritative device state without replaying expired activity.

Use fault injection at the boundaries: delay writes, close connections between send and acknowledgment, restart a fan-out worker, and make one client stop reading. A global load test can miss the slow-consumer failure because aggregate throughput still looks healthy. Inspect the per-client queues.

For the UI, test lease expiry with a controllable clock. A stop message may be lost, so the indicator must clear without receiving one. Also test multiple tabs for the same operator; decide whether actor identity is per user, session, or tab, then make the coalescing key match that choice. Ambiguity there creates indicators that flicker or never clear.

This design has a real limitation: lossy activity cannot support an audit trail, occupancy accounting, or a later query such as "who was composing at 14:03?" If that history is a business requirement, typing is no longer ephemeral presence; model it as a separate durable domain event and accept the storage, privacy, retention, and replay obligations. The trade-off is explicit. Do not quietly turn the best-effort channel into a source of record.

Roll back the optional signal first

Ship activity fan-out behind an independently controlled flag or sampling rate. The rollback is to stop accepting and emitting typing leases while leaving device-status delivery untouched. Do not roll back the shared transport merely because the optional feature is noisy.

During rollback, clients must already tolerate silence and clear existing indicators at expiry. Servers can drain or discard the activity queue immediately because its contents have no value after the lease. Keep status cursors, versions, snapshots, and acknowledgments intact.

The final decision is narrow. If a typing hint prevents operators from colliding in a shared note often enough to justify its operational surface, keep it as lossy presence data. This approach is a poor fit when policy requires a complete activity record, and the feature is not worth the chatter when operators cannot act on it. If the team cannot isolate its queues, expiry, metrics, and kill switch from device status, skip it. The dashboard's primary promise is current device state; optional social context cannot weaken that promise.

References

Top comments (0)