Typing indicators over WebSockets #

“Ada is typing…” looks like the simplest real-time feature in a chat product, and it is responsible for a surprising share of chat traffic. A naive client sends an event on every keystroke, the server broadcasts each one to the whole room, and a busy channel generates more typing events than messages. Then the indicator gets stuck: someone starts typing, closes their laptop, and the room shows them typing for the rest of the afternoon. A typing indicator is an ephemeral, time-bounded signal, and it needs exactly two properties that keystroke-forwarding lacks: it must be rate-limited at the source and it must expire on its own without relying on a “stopped typing” message that may never be sent.

Root cause #

Typing state has three characteristics that shape its design. It is high-frequency at the source — a fast typist produces five to ten keystrokes a second — but low-information: the room only needs to know “typing” or “not typing”, which changes rarely. It is ephemeral: nobody needs it persisted or replayed, and a stale value is worse than none. And its end is usually implicit: people stop typing by sending the message, switching tabs, deleting the draft, or losing their connection, and only some of those produce an explicit event.

Forwarding every keystroke gets the first property wrong and multiplies room fan-out by keystroke rate. Relying on an explicit stop event gets the third wrong, producing ghosts whenever the client disappears. Both are the same class of problem as ghost users in presence systems, covered in fixing presence flapping and ghost users.

Typing deliveries per second with 10% of the room typing Forwarding every keystroke at six per second produces 480 deliveries per second in a ten-person room and nearly ten thousand in a two-hundred-person room; throttling to one event every three seconds cuts that by about thirty times. Typing deliveries per second with 10% of the room typing each event fans out to every other member per keystroke (6/s) throttled (1 per 3 s) 0 2.5k 5.0k 7.5k 10k 10-person room 2.5k 50-person room 200-person room
Throttling at the source removes almost all typing traffic without changing what anyone sees.

Resolution #

On the client, send typing.start at most once per interval while the user keeps typing, and typing.stop when they send, clear the input, or blur it. On the server, treat every start as a lease with a short TTL: the user is “typing” until the lease expires or a stop arrives, and a lease that is not renewed simply lapses. Broadcast only transitions — not typing to typing, and back — so renewals cost the room nothing.

// ---------------- Client ----------------
const TYPING_RENEW_MS = 3_000; // resend "start" at most this often while typing
const IDLE_STOP_MS = 5_000; // no keystrokes for this long => stop

export function wireTypingIndicator(input: HTMLInputElement, send: (m: object) => void, roomId: string) {
let lastStartSent = 0;
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let typing = false;

const stop = () => {
clearTimeout(idleTimer);
if (!typing) return;
typing = false;
lastStartSent = 0;
send({ type: 'typing.stop', roomId });
};

input.addEventListener('input', () => {
if (input.value.length === 0) return stop(); // cleared the draft
const now = Date.now();
if (!typing || now - lastStartSent >= TYPING_RENEW_MS) {
typing = true;
lastStartSent = now;
send({ type: 'typing.start', roomId }); // start or renew the lease
}
clearTimeout(idleTimer);
idleTimer = setTimeout(stop, IDLE_STOP_MS); // paused typing => stop
});
input.addEventListener('blur', stop);
return stop; // call on message send too
}

// ---------------- Server ----------------
const LEASE_TTL_MS = 6_000; // > renew interval, so one lost renewal doesn't flicker

type Broadcast = (roomId: string, msg: object, exceptUserId?: string) => void;
const leases = new Map<string, Map<string, ReturnType<typeof setTimeout>>>(); // room -> user -> expiry

export function onTypingStart(roomId: string, userId: string, broadcast: Broadcast) {
let room = leases.get(roomId);
if (!room) leases.set(roomId, (room = new Map()));
const existing = room.get(userId);
if (existing) clearTimeout(existing); // renewal: no broadcast
else broadcast(roomId, { type: 'typing', userId, typing: true }, userId); // transition only
room.set(userId, setTimeout(() => onTypingStop(roomId, userId, broadcast), LEASE_TTL_MS));
}

export function onTypingStop(roomId: string, userId: string, broadcast: Broadcast) {
const room = leases.get(roomId);
const t = room?.get(userId);
if (!t) return;
clearTimeout(t);
room!.delete(userId);
if (room!.size === 0) leases.delete(roomId);
broadcast(roomId, { type: 'typing', userId, typing: false }, userId);
}

// Connection closed: end every lease the user held on this connection.
export function onDisconnect(userId: string, roomIds: string[], broadcast: Broadcast) {
for (const r of roomIds) onTypingStop(r, userId, broadcast);
}

Receiving clients should also apply their own timeout — hide an indicator that has not been refreshed or cleared within the lease TTL — so a lost stop event on the server-to-client path cannot leave a ghost either. Three independent expiry paths (client idle timer, server lease, receiver timeout) sound redundant; each covers a failure the others cannot see.

On a multi-node fleet, leases held in memory on one node are invisible to others, but that is fine: only transitions are broadcast, through the normal room fan-out (see scaling WebSocket broadcast with Redis pub/sub), and each node expires the leases of its own connections. There is nothing to store in Redis.

One typing session with leases A keystroke at zero seconds starts a lease and broadcasts typing; renewals at three and six seconds extend the lease without broadcasting; the user pauses at eight seconds and the client's five-second idle timer sends a stop at thirteen, which is broadcast. One typing session with leases 'Ada is typing…' shown keystroke: start → broadcast (0 s) renew (no broadcast) (3 s) renew (no broadcast) (6 s) user pauses (8 s) client idle stop → broadcast (13 s) Two broadcasts for a whole typing session, however many keystrokes
Only the start and the end reach the room.

Edge cases #

Several devices or tabs. A user typing on their phone while their laptop is idle should show as typing. Key leases by user and connection, and consider the user typing while any of their leases is active, broadcasting only when the aggregate flips.

Large rooms. In rooms with hundreds of members, showing individual typers is noisy anyway. Broadcast a count (“several people are typing”) at most once per second, or send typing state only to members who have the room open and focused. The awareness patterns in broadcasting cursor and awareness state apply.

Privacy. Typing indicators leak activity — including in direct messages, where “typing… stopped” can be read as hesitation. Offer a setting to disable sending them, and never log them.

Verification #

Measure outbound typing events per typing session: with the client above it should be one start, a renewal every three seconds, and one stop — never one per keystroke. Test the failure paths explicitly: start typing, then kill the tab; other members should see the indicator disappear within the lease TTL. Start typing, then disconnect the network on the typing client; the server’s lease expiry and the receivers’ own timeout should both clear it.

// Receiver-side safety net: hide indicators not refreshed within the lease TTL.
const RECEIVER_TIMEOUT_MS = 7_000;
const hideTimers = new Map<string, ReturnType<typeof setTimeout>>();
export function onTypingEvent(userId: string, typing: boolean, render: (u: string, t: boolean) => void) {
clearTimeout(hideTimers.get(userId));
render(userId, typing);
if (typing) hideTimers.set(userId, setTimeout(() => render(userId, false), RECEIVER_TIMEOUT_MS));
}
The ghost-indicator failure, and its three cures A typist starts typing and the room member sees the indicator; the typist's laptop closes without sending a stop; the server's lease expires after six seconds and broadcasts typing false, and the receiver's own timeout would have hidden it as well. The ghost-indicator failure, and its three cures Typist Server Room member typing.start typing: true laptop closes, no stop sent lease expires at 6 s typing: false (receiver timeout would also hide it) No path depends on the typist's client behaving well at the end
Leases make the end of typing a timeout, not a message.

Operational checklist #

FAQ #

How often should a client send typing events? #

Once when typing starts, then a renewal every few seconds while it continues, and a stop when it ends. Around three seconds between renewals, with a server lease of about six seconds, keeps the indicator steady without flooding the room.

Why do typing indicators get stuck? #

Because the “stopped typing” message was never sent or never arrived — the client closed, crashed or lost its connection. Make typing a lease with a TTL on the server, and have receivers time out indicators too.

Should typing state be stored in Redis? #

Usually not. It is ephemeral and only transitions need to be shared, which the normal room broadcast does. Each node expires leases for its own connections, so there is no shared state to store.

How do I show typing indicators in a room with 500 people? #

Aggregate: broadcast “N people are typing” at a limited rate, or only send typing events to members actively viewing the room. Individual names for hundreds of members are noise and cost quadratic fan-out.

Back to Presence & Online Tracking.