Sign-ups paused

Sign-ups and billing are temporarily unavailable while we rebuild our infrastructure. The SDK and documentation remain available, and existing accounts are unaffected.

Vaultrice

Blog / Tutorials

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.

4 min read Updated 2026-08-XX @vaultrice/sdk

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.