Typing Indicators with SSE Permalink to this section
Part of Collaborative Presence & Live Updates, under Real-Time Application Patterns.
A typing indicator carries almost no information and generates a surprising amount of traffic. Implemented naively — a signal on every keystroke, a “stopped typing” message when the input blurs — it floods the upstream, sticks on screen when the stop message is lost, and flickers between words. This guide builds the indicator the way chat products do: an edge-triggered start signal, periodic renewals while typing continues, and an indicator that expires on its own on every receiving client.
Symptom & Developer Intent Permalink to this section
- “Ana is typing…” stays on screen for minutes after Ana closed the tab.
- The indicator flickers off and on between every word.
- The typing endpoint receives more requests than every other endpoint combined.
- Users see their own typing indicator.
- In a busy room, the indicator line changes so fast it is unreadable.
The intent is an indicator that appears within a few hundred milliseconds of someone starting to type, stays steady while they type, disappears a few seconds after they stop or leave by any route, and costs a handful of requests per minute per typing user.
Root Cause Analysis Permalink to this section
The naive design is level-triggered: every keystroke says “I am typing”, and a separate message says “I stopped”. The start side is wasteful — thirty keystrokes a minute is thirty requests for one bit of information that has not changed. The stop side is unreliable — blur and unload events do not fire when a device sleeps or loses its connection, so the stop message is often never sent, and the indicator sticks.
The fix inverts both halves. The sender signals only when typing starts and then renews at a slow, fixed interval while typing continues. Each receiver treats the indicator as a lease: shown until a deadline, extended by each renewal, and removed when the deadline passes. No stop message is needed for correctness, so a lost one cannot cause a stuck indicator.
Step-by-Step Resolution Permalink to this section
Step 1 — Send edge-triggered starts and slow renewals Permalink to this section
// typing-sender.js
const RENEW_MS = 4000; // renew while typing continues
const IDLE_MS = 3000; // stop renewing after this long without a keystroke
export function createTypingSender(post) {
let lastKey = 0, lastSent = 0, idleTimer = null;
return function onKeystroke() {
const now = Date.now();
lastKey = now;
if (now - lastSent >= RENEW_MS) { // first key, or time to renew
lastSent = now;
post({ kind: 'typing', data: { until: now + RENEW_MS + 2000 } });
}
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
lastSent = 0; // next key will send a fresh start
post({ kind: 'typing', data: { until: 0 } }); // courtesy stop: fast path only
}, IDLE_MS);
};
}
The until field tells receivers how long to show the indicator, so the policy lives with the sender and receivers stay simple. The courtesy stop makes the indicator disappear promptly when the user pauses; if it is lost, until handles it.
Step 2 — Relay typing as an ephemeral, droppable signal Permalink to this section
app.post('/api/rooms/:id/signal', requireMember, rateLimit({ perMinute: 60 }), async (req, res) => {
const { kind, data } = req.body;
if (kind !== 'typing') return res.status(400).end();
const until = Math.min(Number(data.until) || 0, Date.now() + 10_000); // clamp client input
await bus.publish(`room:${req.params.id}`, JSON.stringify({
type: 'signal', kind, user: req.user.id, name: req.user.name, conn: req.get('X-Conn-Id'), until,
}));
res.status(204).end();
});
On the stream side, typing signals are written without an id and skipped for viewers whose socket is backed up — exactly like cursor signals in the collaborative presence topic.
Step 3 — Expire indicators on the receiver Permalink to this section
// typing-receiver.js
const typing = new Map(); // userId → { name, until }
let sweep = null;
es.addEventListener('signal', (e) => {
const s = JSON.parse(e.data);
if (s.kind !== 'typing' || s.conn === myConnId) return; // never show myself
if (s.until > Date.now()) typing.set(s.user, { name: s.name, until: s.until });
else typing.delete(s.user);
renderTyping();
sweep ??= setInterval(() => {
const now = Date.now();
for (const [u, t] of typing) if (t.until <= now) typing.delete(u);
renderTyping();
if (!typing.size) { clearInterval(sweep); sweep = null; }
}, 1000);
});
Receivers compare until against their own clock. A few seconds of skew between client clocks only lengthens or shortens the indicator slightly; if skew matters, send server time in heartbeats and compute an offset, as described in choosing heartbeat intervals.
Step 4 — Clear the indicator when a message is sent Permalink to this section
When the user sends their message, the message itself should clear their indicator on every client — no separate stop signal needed:
es.addEventListener('message', (e) => {
const m = JSON.parse(e.data);
typing.delete(m.user); // they finished typing: the message proves it
renderTyping();
appendMessage(m);
});
Step 5 — Summarise when several people type Permalink to this section
function renderTyping() {
const names = [...typing.values()].map((t) => t.name).sort();
el.textContent =
names.length === 0 ? '' :
names.length === 1 ? `${names[0]} is typing…` :
names.length === 2 ? `${names[0]} and ${names[1]} are typing…` :
'Several people are typing…';
el.hidden = names.length === 0;
}
Reserve the indicator’s space in the layout even when it is empty, so the message list does not jump every time someone starts or stops typing. For assistive technology, do not put the indicator in a live region: announcing every start and stop is noise. If a screen reader user benefits from knowing someone is replying, announce it at most once per conversation turn, as described in announcing live updates to screen readers.
In large rooms, cap how many typing users the server relays. Beyond three or four, the indicator reads “Several people are typing…” regardless, so the relay can keep a per-room set of typists and publish only when that set crosses the threshold, instead of forwarding every renewal from every participant.
Validation & Monitoring Permalink to this section
# Rate: typing continuously for 60 s should produce roughly 15 requests, not hundreds.
grep 'POST /api/rooms/.*/signal' access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head
# Stuck-indicator test: start typing in one browser, then kill it; the indicator must
# disappear from the other browser within about six seconds.
Automate the second test with Playwright: two browser contexts, one types then has its context closed abruptly, and the other asserts the indicator element becomes hidden within the deadline. The approach is covered in end-to-end testing SSE with Playwright.
In production, watch typing signals per active user per minute. A value far above fifteen means a client is sending per keystroke; one near zero while messages are flowing means the sender is broken.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why not send a stop signal on blur or unload?
Send one as a courtesy, but never rely on it. Blur and unload do not fire reliably on mobile, on sleep or on network loss, so the receiver-side deadline is what guarantees the indicator disappears.
How long should the indicator stay after the last signal?
A few seconds longer than the renewal interval, so one delayed renewal does not cause flicker. With renewals every four seconds, a six-second deadline works well.
Should typing signals be stored or replayed?
No. They are only meaningful for seconds. Publish without an id, never persist them, and let reconnecting clients start with no indicators.
Is a typing indicator worth building at all?
In conversational products it reduces duplicate replies and makes waiting feel shorter. In document editors, cursors and selections usually communicate the same thing better.