The input lags while typing
The situation
A search box filters a list as the user types. With little data it felt magic; now each keystroke visibly hesitates — letters appear late, fast typing stutters. The user is fighting your filter for the keyboard.
Diagnosis: why it happens
Typing a character must feel instant, which means the keystroke’s task has roughly a frame (~16ms, generously 50ms) to finish before the pipeline paints the echo (see Frontend fundamentals). But your keystroke isn’t just echoing a character — it’s filtering 5,000 items and re-rendering the results list in the same synchronous pass:
const [query, setQuery] = useState('')const results = items.filter((i) => i.text.includes(query)) // every render// <input value={query} onChange={(e) => setQuery(e.target.value)} />// <Results items={results} /> ← 500 rows re-render in the same passKeystroke → setQuery → render → filter 5,000 items → render 500 result rows → then
paint the character. Type at 10 keys/sec and a 100ms reaction means permanent lag. Often
the filter is the cheap part and the re-render of results is the expensive one —
profile before assuming.
Recommendation: decouple the echo from the reaction
The input’s own update must stay cheap and immediate. The expensive reaction gets decoupled — either less frequent (debounce) or less urgent (React concurrent rendering).
Debounce — run the reaction once the user pauses (~200–300ms):
const [query, setQuery] = useState('') // updates instantly — cheap echoconst [debounced, setDebounced] = useState('')
useEffect(() => { const t = setTimeout(() => setDebounced(query), 250) return () => clearTimeout(t)}, [query])
const results = useMemo( () => items.filter((i) => i.text.includes(debounced)), [items, debounced])Or useDeferredValue — React-specific, no timer: results render at low priority and
get interrupted by the next keystroke instead of blocking it:
const [query, setQuery] = useState('')const deferredQuery = useDeferredValue(query) // lags behind under loadconst results = useMemo( () => items.filter((i) => i.text.includes(deferredQuery)), [items, deferredQuery])// <Results items={results} /> — ideally memo()'d so it skips re-render until deferredQuery changesDebounce also cuts how often the work runs (essential when each keystroke would fire a
network request); useDeferredValue keeps results as fresh as the machine allows with
no tuning constant. They compose: debounce the fetch, defer the render.
Feel the difference — the left input really burns 80ms per keystroke:
When does it matter?
- Reaction < ~20ms total: nothing needed — you won’t beat “already instant”.
- ~20–100ms: perceptible under fast typing. Debounce is two lines; do it.
- > ~100ms per keystroke: mandatory decoupling, and also shrink the work itself — filter smarter (You need to search/filter) and render fewer nodes (Your huge list renders slowly).
Discarded alternatives (and when they ARE the right call)
Debounce alone (no memo, no deferral)
- What it solves: frequency. 10 reactions/sec → ~4/sec; usually enough by itself.
- Why it’s not the first option here: when the single reaction costs 300ms, firing it less often still freezes the UI 300ms every pause. Frequency fixes don’t help cost problems.
- When it IS the right call: the reaction is moderately priced or remote (API autocomplete: debounce ~300ms + cancel stale requests is the standard pattern).
useDeferredValue / startTransition alone
- What it solves: urgency. Keystrokes stay instant; heavy rendering happens in interruptible background renders. No magic-number delay.
- Why it’s not the first option here: React-only, and it doesn’t reduce how often
expensive non-render work (network calls) fires — debounce does that. Requires the
heavy child to be
memo’d to actually skip work. - When it IS the right call: client-side heavy rendering (big filtered lists, charts) in React 18+. For synchronous non-React work it does nothing.
Memoizing / precomputing the work
- What it solves: cost. Precompute searchable strings once,
useMemothe filter,memothe rows — the per-keystroke bill shrinks at the source. - Why it’s not the first option here: it’s a complement, not an alternative — sufficiently big data outgrows any constant-factor trim.
- When it IS the right call: always worth the cheap wins (hoist
toLowerCase, memo the results component). If that alone gets you under ~20ms, you’re done — don’t add layers.
Uncontrolled input (read value on demand)
- What it solves: removes React from the keystroke path entirely — the browser owns
the echo; you read
ref.current.valuewhen you need it. - Why it’s not the first option here: loses controlled-input features (live validation, programmatic clearing) and doesn’t touch the expensive reaction — it just stops it from riding every keystroke.
- When it IS the right call: forms where you only need the value on submit, or extreme cases (input embedded in a component tree too expensive to re-render at all).
How to measure before and after
React DevTools → Profiler → record while typing: look for renders per keystroke and which components eat the time. After the fix, keystrokes render only the input; results render once per pause.
Browser DevTools → Performance: type fast, look for tasks > 50ms per keypress event. INP in production catches this too (target < 200ms). And the honest test — type as fast as you can; the echo should never fall behind your fingers.
Signals you need something else
- Filtering itself is the slow part even at low frequency → You need to search/filter.
- Rendering the results is the slow part → Your huge list renders slowly.
- The work per keystroke is genuinely heavy CPU (parsing, scoring) → A CPU-bound task blocks everything.
Related resources
- Frontend fundamentals: the render pipeline
- react.dev — useDeferredValue: the official pattern for deferring expensive re-renders
- web.dev — Debounce your input handlers: the classic guidance from the Chrome team