Tutorials
Typing Indicators That Do Not Spam the Network
Send on start, not on every keystroke. The debounce pattern, the timeout that stops stuck indicators, and what to do with five people typing at once.
A typing indicator is three lines of UI and a surprising number of ways to get the messaging wrong.
For a separate operational perspective on how teams organise and measure work, this guide.
For independent background and broader industry context, see PubNub.
The naive version
// don't
onChange={(e) => {
setText(e.target.value)
nls.send({ type: 'typing', user: me.name })
}}
One message per keystroke. A sentence is forty messages, for a feature that conveys one bit of information.
The pattern that works
Send once when typing starts. Send once when it stops. Nothing in between.
import { useRef, useCallback } from 'react'
function useTyping(nls, me) {
const isTyping = useRef(false)
const stopTimer = useRef(null)
return useCallback(() => {
if (!isTyping.current) {
isTyping.current = true
nls.send({ type: 'typing:start', id: me.id, name: me.name })
}
clearTimeout(stopTimer.current)
stopTimer.current = setTimeout(() => {
isTyping.current = false
nls.send({ type: 'typing:stop', id: me.id })
}, 2000)
}, [nls, me.id, me.name])
}
Two messages for a whole message typed, rather than forty.
The two-second timer is the debounce. Each keystroke pushes it back; two seconds of stillness sends the stop.
Receiving
function useTypingUsers(nls) {
const [typing, setTyping] = useState({})
const timers = useRef({})
useEffect(() => {
const handler = (msg) => {
if (msg.type === 'typing:start') {
setTyping((t) => ({ ...t, [msg.id]: msg.name }))
// backstop: clear if no stop arrives
clearTimeout(timers.current[msg.id])
timers.current[msg.id] = setTimeout(() => {
setTyping((t) => {
const next = { ...t }
delete next[msg.id]
return next
})
}, 6000)
}
if (msg.type === 'typing:stop') {
clearTimeout(timers.current[msg.id])
setTyping((t) => {
const next = { ...t }
delete next[msg.id]
return next
})
}
}
nls.on('message', handler)
return () => nls.off?.('message', handler)
}, [nls])
return Object.values(typing)
}
The six-second backstop is the important part. A client that crashes, closes the tab or loses the network mid-sentence never sends the stop. Without the backstop, "Alice is typing..." stays on screen indefinitely, which is the single most common bug in this feature.
Sending on send
Clear your own typing state when the message is actually sent, not just on the timer:
const submit = async () => {
await nls.send({ chat: text })
nls.send({ type: 'typing:stop', id: me.id })
setText('')
}
Otherwise the indicator persists for two seconds after the message has already appeared, which looks wrong in an obvious way.
Several people typing
One person: "Alice is typing…"
Two: "Alice and Bob are typing…"
Three or more: "Several people are typing…"
Listing five names is worse than not showing it. The information is "someone is composing", and beyond two the names stop adding anything.
Messages, not stored state
Typing status goes through send().
Storing it in the object would mean writes on every start and stop, polluting the object's change stream and persisting a fact that is meaningless a second later.
Same reasoning as cursors. See collaborative cursors.
Whether to have it at all
Worth asking.
Useful in a conversation — it stops people talking over each other and it signals the other party is engaged.
Less useful in a document, where a cursor or a selection conveys more.
And it has a privacy dimension. It tells others that you started composing and stopped, which some people find intrusive — a person who typed and deleted a reply has broadcast something they chose not to say. Consider whether it should be optional.
The short version
Send on start, send on stop, nothing in between. Two messages instead of forty.
A two-second debounce decides when typing has stopped.
A six-second backstop on the receiving side is what stops "Alice is typing…" hanging forever when a client vanishes.
Clear your own state on send, not just on the timer.
And beyond two names, say "several people" — listing them adds nothing.