DEV Community

Silhouette72591483
Silhouette72591483

Posted on

Typing Indicator Sticks Forever — Debug Missing Stop Events with Client Expiry

A typing indicator has an awkward constraint: the event that ends it is the event most likely to disappear. A shopper can close a tab, move between networks, or reconnect while editing a shared product note, and none of those transitions owes another client a final stop message. Treating stop as the only path back to idle therefore turns a transient hint into durable misinformation.

TL;DR: model typing as a renewable lease, not a boolean. A start or refresh event sets a short local expiry; explicit stop clears it early; reconnect starts from idle and accepts only fresh session-scoped events. Do not backfill typing history. Backfill the product-note document and its revision, because that state is durable; let presence rebuild from new activity.

How do you debug a typing indicator that sticks forever?

There is no single failure to patch. The sender may vanish before its final frame is transmitted. A frame may also arrive after a reconnect, when the same user has a new connection. If the application uses a peer data channel, the WebRTC specification exposes ordered delivery and retransmission controls, but application meaning still belongs above the transport: delivery of a message does not make an old typing session current.

The nastiest variant is reordering across sessions. Imagine editor buyer-42 emits start in session A, reconnects into session B, and then an old stop from A reaches a subscriber. A bare {user_id, typing} record cannot distinguish those timelines. If the subscriber applies that stop after session B starts, the indicator disappears even while the editor is active; if a delayed start from A lands last, it sticks forever. Looking only at the final boolean hides the cause, so the useful debugging artifact is a timeline containing both session generations and their sequence values. Both outcomes are plausible once separate connections are allowed to race, and increasing the timeout changes neither ordering.

History is poison here.

No durable record fixes that ambiguity by itself. Persisting every pulse can actually preserve the wrong thing: after recovery, a consumer replays evidence that somebody was typing seconds ago and presents it as evidence that they are typing now. Ephemeral state needs bounded lifetime.

Make typing a lease

Use an envelope with a user identifier, an editor-session identifier, a monotonically increasing sequence within that session, and a sender timestamp used for diagnostics. The receiver should base expiry on its own monotonic clock; wall clocks on two devices need not agree, and a wall-clock adjustment must not stretch a lease.

The following state machine is deliberately small. The example uses a 5-second lease (5,000 ms) as a design value, not a universal constant; choose the real value from the application's refresh cadence and interaction tolerance, then test it under delay.

from dataclasses import dataclass
from time import monotonic

LEASE_SECONDS = 5.0

@dataclass
class TypingLease:
    session_id: str
    sequence: int
    expires_at: float

leases: dict[str, TypingLease] = {}

def apply_typing_event(event: dict, now: float | None = None) -> None:
    now = monotonic() if now is None else now
    user_id = event["user_id"]
    current = leases.get(user_id)

    same_session = current and current.session_id == event["session_id"]
    if same_session and event["sequence"] <= current.sequence:
        return

    if event["kind"] == "stop":
        if same_session:
            leases.pop(user_id, None)
        return

    leases[user_id] = TypingLease(
        session_id=event["session_id"],
        sequence=event["sequence"],
        expires_at=now + LEASE_SECONDS,
    )

def visible_typists(now: float | None = None) -> list[str]:
    now = monotonic() if now is None else now
    expired = [user_id for user_id, lease in leases.items()
               if lease.expires_at <= now]
    for user_id in expired:
        leases.pop(user_id, None)
    return sorted(leases)
Enter fullscreen mode Exit fullscreen mode

A refresh while keys continue moving renews the lease. A stop remains useful because it removes the indicator promptly, but correctness no longer depends on it. Sequence checks suppress duplicates and older events inside one session; the session ID prevents a late event from an abandoned connection from overwriting a newer session.

There is a sharp edge in the sample: a stop for an unseen session is ignored. That is intentional. Allowing it to clear an unrelated active session would trade a stuck indicator for a flickering or prematurely hidden one.

That is the trade-off.

Reconnect and backfill are separate lanes

After reconnect, first restore the shared product note from a durable snapshot plus ordered changes, or from whatever revision protocol the editor already uses. Only after the client knows the current document revision should it publish a new presence session. A typing pulse is never part of that historical stream.

This separation matters because document recovery and presence recovery have different truth conditions. Document recovery asks, "Have I reconstructed every accepted edit through revision R?" Presence asks, "Have I observed recent activity from this live session?" Combining them creates a replay trap in which an entirely valid historical event produces an invalid current UI.

State Recovery rule Failure if replayed blindly
Product-note content Backfill to an agreed revision Missing or duplicated edits
Cursor position Re-announce after document revision is known Position refers to stale text
Typing indicator Rebuild from fresh activity; expire locally Ghost typing after reconnect

On disconnect, clear remote leases immediately or let them expire, depending on whether the UI knows the transport is gone. On reconnect, assign a new session ID and reset its sequence. Do not reuse the last session merely to make counters look continuous; continuity is precisely what has been lost.

Debug the failure as a timeline

Start with evidence, not a longer timeout. Log the event kind, user ID, session ID, sequence, local receive time, computed expiry, connection generation, and the reason an indicator was removed. Avoid logging note text or keystrokes; neither is needed to diagnose lease transitions.

Then reproduce four paths: normal start/refresh/stop, sender disappearance without stop, delayed events from an old connection, and reconnect followed by document backfill. Use a controllable clock in unit tests so five seconds does not become five seconds of test-suite sleep. Assert both visibility and deletion from the lease map.

def test_missing_stop_expires() -> None:
    leases.clear()
    apply_typing_event({
        "user_id": "buyer-42",
        "session_id": "session-b",
        "sequence": 7,
        "kind": "refresh",
    }, now=100.0)

    assert visible_typists(now=104.9) == ["buyer-42"]
    assert visible_typists(now=105.0) == []
Enter fullscreen mode Exit fullscreen mode

Watch two operational signals together: lease expirations and explicit stops. A rise in expirations can identify a delivery or lifecycle problem, while a permanent zero can expose a cleanup timer that is not running. Counts need context, however; a browser close naturally produces expiry without proving a transport defect. Sample traces across connection generations are more useful than a single aggregate.

The decision table is compact because the invariant is compact.

Choice Handles missing stop Handles stale session Main cost
Boolean plus stop No No Lowest message and state complexity
Lease only Yes Partly Refresh traffic and timers
Lease plus session and sequence Yes Yes Larger envelope and stricter state machine

For a collaborative editor, the last row earns its complexity once reconnect is supported. The indicator is allowed to disappear briefly. It is not allowed to claim activity forever.

The limitation is real: a lease can hide a genuinely active typist when refresh delivery is delayed beyond the expiry, while a longer lease leaves stale UI visible for longer. This pattern is also a poor fit for state that must survive a process restart or support an audit, because expiry deliberately discards history. Durable edits need acknowledged storage and replay; typing hints do not. Teams must set the bound from their refresh schedule and tested delay envelope, then accept either earlier disappearance or longer staleness rather than pretending both can be eliminated.

No timer removes that choice.

Roll it out without preserving ghosts

Deploy receivers that understand leases before senders begin emitting refreshes. During the transition, receivers can translate a legacy start into a lease and a legacy stop into an early clear. Set a bounded expiry for every path; otherwise the compatibility branch recreates the original bug.

Next, add session and sequence fields, observe discarded stale events, and exercise reconnect under forced delay. Finally, remove historical typing events from the backfill path while retaining durable document recovery. The migration is complete when every visible indicator has an expiry, every reconnect creates a new presence session, and replaying stored document changes cannot resurrect typing state.

Sources

Top comments (0)