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.

Signals sent while one user types two sentences Timeline of thirty seconds of typing comparing a per-keystroke design, which sends a signal on every key, with an edge-triggered design that sends a start signal and a renewal every four seconds. Signals sent while one user types two sentences Per keystroke Edge + renew ~50 signals ~55 signals 0 6 12 18 24 30 seconds pause resumes
The edge-triggered design sends seven signals where the keystroke design sends over a hundred, and the indicator on other screens looks identical.
Level-triggered versus deadline-based indicators Two panels comparing a level-triggered typing indicator that depends on start and stop messages with a deadline-based indicator that expires on the receiver. Level-triggered versus deadline-based indicators Level-triggered signal on every keystroke explicit stop on blur lost stop = stuck forever cost grows with typing speed Deadline-based signal on start, renew slowly receiver expires at until lost stop = gone in seconds cost fixed per minute typed
The deadline model turns a lost message from a stuck indicator into, at worst, one that lingers for a few seconds.

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.

One typing session end to end Flow from a first keystroke through an edge-triggered start signal, renewals every four seconds, the relay, and receivers that expire the indicator at the until deadline. One typing session end to end First key edge trigger once POST start until +6 s while typing Renew every 4 s publish Relay no id, droppable SSE Receivers expire at until
Correctness lives at the right-hand end. Every receiver enforces the deadline, so no message has to arrive for the indicator to go away.

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.