The hard part of a typing indicator is not drawing three dots. It is deciding which signal wins when a driver loses a radio connection while a dispatch session is polling for updates. For a logistics live poll, throttle typing events on the server, expire each client-side hint, and treat delivery as best effort. That keeps fan-out bounded without leaving a stale “typing” badge on screen.
Short answer: send at most one typing event per user every 300 ms, attach a 1.5-second expiry to the client state, and clear it on stop or disconnect. The poll's answers still need their own acknowledged path; a typing hint must never be the source of truth.
The decision matrix for a live dispatch poll
| Choice | Good fit | Failure you accept |
|---|---|---|
| WebSocket with an in-process throttle | One Node.js process, modest rooms, fast first call | Restart loses ephemeral state |
| WebSocket plus shared rate state | Several app replicas and uneven traffic | Extra network hop and clock coordination |
| Managed realtime relay | Small team with little connection infrastructure | Provider-specific semantics and another operational boundary |
Socket.IO, Pusher, and Ably can all carry this class of event, but they expose different retry, presence, and fan-out controls. Compare those behaviors in a load test; a familiar API is not a delivery guarantee. For a small dispatch tool, I start with the standards-shaped interface below and add a shared limiter only when a second process is real, not hypothetical.
The test case I keep on my desk is deliberately unglamorous: twelve dispatchers, one warehouse room, and a flaky LTE link on the loading dock. Dispatcher A types, the socket reconnects, and Dispatcher B opens the poll while an old typing packet is still buffered. The correct result is that B sees either a short-lived hint or nothing; B must still receive the poll snapshot and its version. I inject a 900 ms pause, duplicate one packet, and close the socket after the server accepts the answer. If the answer disappears, the protocol is wrong. If the typing badge lingers for 10 seconds, the expiry is wrong. Those are separate assertions, and combining them in one end-to-end test hides the useful failure.
How should a Node.js typing indicator use server throttle and client expiry?
The server should make the noisy decision. A browser can emit on every keypress, so the server keeps a per-session timestamp and forwards only events outside the throttle window. The event includes an absolute expiresAt; clients do not need synchronized clocks to use it if they calculate the delay from receipt.
Keep hints disposable.
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
const app = express();
const server = app.listen(3000);
const wss = new WebSocketServer({ server });
const lastSent = new Map<string, number>();
const THROTTLE_MS = 300;
const DISPLAY_MS = 1500;
function broadcast(room: Set<WebSocket>, message: object): void {
const data = JSON.stringify(message);
for (const client of room) {
if (client.readyState === WebSocket.OPEN) client.send(data);
}
}
wss.on("connection", (socket) => {
const room = new Set<WebSocket>([socket]);
socket.on("message", (raw) => {
const event = JSON.parse(raw.toString()) as { type: string; userId: string };
if (event.type === "typing") {
const now = Date.now();
const previous = lastSent.get(event.userId) ?? 0;
if (now - previous < THROTTLE_MS) return;
lastSent.set(event.userId, now);
broadcast(room, { type: "typing", userId: event.userId, expiresAt: now + DISPLAY_MS });
}
if (event.type === "stop-typing") {
broadcast(room, { type: "stop-typing", userId: event.userId });
}
});
socket.on("close", () => room.delete(socket));
});
This example intentionally keeps room membership local. In a multi-process deployment, the throttle key must live in shared state or be enforced at the gateway; otherwise two replicas can each forward the same user's burst. Do not quietly turn a hint into a queue. Queueing old keystrokes makes the indicator appear after the driver has stopped.
There is a cost to the simple map: memory grows with user IDs until cleanup, and a process restart forgets its timestamps. That is acceptable for one instance and a short-lived signal. It is not suitable when a compliance-sensitive room needs an auditable, cross-region event history; stick with an acknowledged event log for the poll and keep the typing stream disposable.
That distinction also changes what I put in dashboards. A spike in typing events is a capacity clue, not an incident. A missing poll acknowledgement is an incident. Alerting on the first produces noise precisely when operators are already dealing with a real delivery problem.
On the client, expiry is a timer, not a server callback. A late packet cannot keep the badge alive because its deadline is compared with the current timer generation.
type TypingState = { timer?: ReturnType<typeof setTimeout>; generation: number };
const typing = new Map<string, TypingState>();
function showTyping(userId: string, expiresAt: number): void {
const old = typing.get(userId);
if (old?.timer) clearTimeout(old.timer);
const generation = (old?.generation ?? 0) + 1;
const delay = Math.max(0, expiresAt - Date.now());
const timer = setTimeout(() => {
if (typing.get(userId)?.generation === generation) {
typing.delete(userId);
renderTyping(userId, false);
}
}, delay);
typing.set(userId, { timer, generation });
renderTyping(userId, true);
}
function renderTyping(userId: string, visible: boolean): void {
document.querySelector(`[data-user="${userId}"]`)
?.classList.toggle("is-typing", visible);
}
What does delivery guarantee mean at fan-out?
Typing is lossy by design. If one event disappears, the next keypress refreshes the hint; if a stop event disappears, expiry repairs the UI. The live poll itself is different: publish a poll version, acknowledge the answer, and let reconnecting clients fetch the latest version. Keep those channels separate so a backpressure policy for chat noise cannot delay a dispatch decision.
Measure three things: forwarded events per room, expiry-cleared indicators, and poll answers acknowledged within the session deadline. I also log a correlation ID, room ID, and sequence number, but never the driver's message text. A 300 ms throttle is a starting point, not a law; your mileage may vary with keyboard cadence and radio latency.
When should the runner-up be used?
The in-process design is not suitable when replicas must agree on one user's rate limit, or when a room spans regions. Use shared rate state or a relay then, and test reconnect behavior rather than trusting a demo. A managed product may reduce connection plumbing, while a self-hosted WebSocket layer gives tighter control over retention and network placement. Socket.IO's fallback transports, Pusher's hosted channels, and Ably's pub/sub model are useful comparison points, but each has a different contract for retries and ordering; read those contracts before promising “realtime.”
I first assumed a shorter expiry would feel snappier. It made weak-signal sessions flicker. The safer rule is boring: throttle upstream, expire downstream, and never use a presence hint to decide whether a poll answer was delivered.
One more boundary matters: client expiry is a rendering policy, not proof that the sender stopped typing. A backgrounded browser can pause timers, and a mobile OS can suspend the socket. On resume, clear local state and wait for a fresh event. Your mileage may vary across browsers, so test visibility changes as well as network loss.
Top comments (0)