How to Fix Slow INP on Text Inputs and Typeahead Search

This guide applies Profiling Event Handlers for INP to the interaction users perform most often, within Core Web Vitals & Measurement: typing. Every keystroke is an interaction — keydown, beforeinput, input, keyup — and INP takes the worst across the visit. A search page where each keystroke filters 3,000 rows in the handler can easily produce 300ms+ keystrokes on mid-tier phones, and with ten or twenty keystrokes per session, one of them will be slow.

Typing is unforgiving because users notice latency in their own characters appearing. A delay of 100ms between pressing a key and seeing the character feels sluggish; 300ms feels broken. The fix always has the same shape: make echoing the character cheap and immediate, and move the expensive work — filtering, validation, suggestions, re-rendering results — out of the keystroke's critical path.

Expensive keystroke vs split keystroke Comparison of a keystroke handler that filters and re-renders synchronously with one that echoes the character immediately and defers the expensive work. Expensive keystroke vs split keystroke All work in the input handler • Update state, filter 3,000 rows • Re-render the results list • Character appears after ~280ms • INP 280ms on every keystroke Echo first, work later • Controlled input updates immediately • Filtering deferred or in a worker • Character appears in ~30ms • Results update a frame or two later

Rapid Diagnosis

  • Record typing in the Performance panel. Type five characters quickly with 4x CPU throttling. Each keystroke shows in the Interactions track; note the slowest and expand it.
  • Look at processing duration. If the input or keydown handler (or the framework's dispatch of it) runs for more than 50ms, the handler is doing too much.
  • Check what re-renders. In React DevTools Profiler, record typing; if the entire results list re-renders on each keystroke, rendering is the cost.
  • Look for network calls per keystroke. Fetching suggestions on every keystroke is usually cheap on the main thread, but parsing and rendering each response is not.
  • Check validation libraries. Schema validation of a whole form on every keystroke can cost tens of milliseconds.

Root Cause Analysis

1. Synchronous filtering in the handler. The handler filters a large array (or runs fuzzy matching) before returning, so processing duration scales with data size.

2. Controlled inputs tied to expensive state. In React, a controlled input whose value lives in state at the top of a large tree re-renders the whole tree on each keystroke, including the results.

3. Rendering many results synchronously. Replacing hundreds of list items per keystroke costs layout and paint, landing in presentation delay.

4. Unthrottled side effects. Analytics events, URL updates (history.replaceState), and storage writes on every keystroke each add a few milliseconds that accumulate.

Where a 280ms keystroke goes (mid-tier phone) Bar chart splitting a slow keystroke interaction into input delay, handler filtering, framework re-render and presentation delay. Where a 280ms keystroke goes (mid-tier phone) Input delay 12ms Filter 3,000 rows 95ms Re-render list (240 items) 118ms Presentation delay 55ms 200ms INP

Step-by-Step Resolution

1. Keep the input's own update cheap and isolated

Put the input value in local state close to the input, so typing re-renders only the input component. Derive the expensive query state separately.

jsx
function SearchBox({ onQuery }) {
  const [text, setText] = useState('');
  return <input value={text} onChange={(e) => { setText(e.target.value); onQuery(e.target.value); }} />;
}
// The parent stores the query in a transition so the results render is interruptible.
function SearchPage() {
  const [query, setQuery] = useState('');
  const [, startTransition] = useTransition();
  return (<>
    <SearchBox onQuery={(q) => startTransition(() => setQuery(q))} />
    <Results query={query} />
  </>);
}
// trade-off: transitions keep typing responsive, but results lag behind the
// input during fast typing. Show a subtle pending state so stale results are
// not mistaken for final ones.

Expected outcome: keystroke processing drops to the cost of updating one input — typically under 20ms. useTransition and useDeferredValue for INP explains the scheduling.

2. Move filtering off the keystroke

For client-side filtering, either debounce it briefly or run it in a worker. Debouncing reduces how often it runs; a worker removes it from the main thread entirely.

javascript
// Filter in a worker; the main thread only posts the query and renders results.
const worker = new Worker(new URL('./search-worker.js', import.meta.url), { type: 'module' });
let latest = 0;
export function search(query) {
  const id = ++latest;
  worker.postMessage({ id, query });
  return new Promise((resolve) => {
    worker.addEventListener('message', function onMsg(e) {
      if (e.data.id !== id) return;                 // ignore stale responses
      worker.removeEventListener('message', onMsg);
      resolve(e.data.results);
    });
  });
}
// trade-off: posting large result arrays back costs structured-clone time on
// the main thread. Return only the IDs (or the first page) of matches, not
// full objects.

Expected outcome: filtering cost leaves INP entirely; see running client-side search in a Web Worker.

3. Render fewer results

Cap rendered suggestions (10–20 for a typeahead) and virtualise full results lists so a keystroke never renders hundreds of rows.

jsx
const visible = results.slice(0, 20);
return <ul role="listbox">{visible.map((r) => <Suggestion key={r.id} item={r} />)}</ul>;
// trade-off: capping hides matches users might want. Provide "See all N results"
// that navigates to a virtualised results page rather than expanding inline.

Expected outcome: presentation delay per keystroke falls to a small, constant cost regardless of match count.

4. Batch side effects

Throttle URL updates, analytics and storage writes to once every few hundred milliseconds, or flush them on blur.

javascript
let pending;
function scheduleUrlUpdate(q) {
  clearTimeout(pending);
  pending = setTimeout(() => history.replaceState(null, '', `?q=${encodeURIComponent(q)}`), 300);
}
// trade-off: a user who shares the URL mid-typing gets a slightly stale query.
// Flush pending updates on blur and before navigation.

Expected outcome: a few milliseconds saved per keystroke and fewer surprises from synchronous storage APIs.

One keystroke in an optimised typeahead Sequence of a keystroke being echoed immediately on the main thread while filtering runs in a worker and results render afterwards. One keystroke in an optimised typeahead User Input Main thread Worker keydown echo char (~10ms) postMessage(query) top 20 ids

Verification

Type a ten-character query in a throttled trace and confirm every keystroke interaction is under 100ms, with processing under 30ms. In RUM, filter INP attribution to interactionType: 'keyboard' and to your search input's selector; its p75 should drop well below 200ms. Keep a scripted typing test in CI:

javascript
await page.goto('/search');
await page.click('#q');
await page.keyboard.type('trail running shoes', { delay: 80 });
const worst = await page.evaluate(() => Math.max(...performance.getEntriesByType('event')
  .filter((e) => e.name === 'keydown' || e.name === 'input').map((e) => e.duration)));
expect(worst).toBeLessThan(120);
// trade-off: Event Timing only buffers entries over a duration threshold
// (16ms minimum). Observe with durationThreshold: 16 before typing if you need
// complete data, rather than relying on the default buffer.

Accessibility and IME Considerations

Fast typing must not come at the cost of correctness. Input Method Editors (IMEs) used for Chinese, Japanese and Korean fire compositionstart/compositionend around composed characters; filtering on every input event during composition both wastes work and produces nonsense queries. Skip expensive work while event.isComposing is true. For screen-reader users, a typeahead that announces result counts on every keystroke is noisy — debounce aria-live updates by around half a second. Both changes also reduce work per keystroke, so accessibility and INP pull in the same direction.

Caching Prefix Results

Typeahead queries are highly repetitive: every query extends the previous one by a character. Cache results by query string, and when a new query extends a cached one, filter the cached (smaller) result set instead of the full dataset. "trai" filtered from the results of "tra" touches a fraction of the items. For server-backed suggestions, the same idea applies with an in-memory LRU of recent responses, which also makes backspacing instant. Prefix caching often reduces filtering cost by an order of magnitude for the second and later characters of a query, which is where most keystrokes are.

FAQ

Is debouncing enough on its own?

Debouncing reduces how often the expensive work runs, which helps total CPU, but when it does run it still runs on the main thread. If a user presses a key while the debounced filter executes, that keystroke inherits the cost as input delay. Combine debouncing with making the work itself cheap or off-thread. The trade-offs are compared in debouncing vs yielding for input events.

Should the input be uncontrolled for performance?

Uncontrolled inputs avoid framework re-renders on each keystroke entirely, which makes them the cheapest option. They are a good fit when you only need the value occasionally (on submit, or debounced). Controlled inputs are fine when their state is local to a small component; the problem is controlled inputs whose state lives high in a large tree.