I was on a call with a client who’d just added a live-search field to their product catalog. The UI felt fine in the dev environment, but as soon as the catalogue grew to a few thousand items the input started to freeze. The cursor would jump, keystrokes were dropped, and the overall experience felt "sluggish". The obvious fix, debounce the API call, was already in place, yet the lag persisted. That’s when I realized the problem wasn’t the network request at all; it was the way React was scheduling the update.

The symptom: UI work gets blocked by data fetching

When you type into a controlled input, React re-renders the component on every keystroke. If the render includes heavy work, for example, filtering a large array, building a query string, or even just calling a hook that triggers a fetch, the main thread is busy. The browser can’t paint the new cursor position until the JavaScript call stack clears. Debounce only delays the fetch; it does nothing to free the UI thread during the render.

In my case the component looked roughly like this:

function Search({ products }) {
  const [term, setTerm] = useState('')
  const [results, setResults] = useState([])

  const handleChange = e => {
    setTerm(e.target.value)
    debouncedFetch(e.target.value)
  }

  const debouncedFetch = useMemo(() =>
    _.debounce(async q => {
      const res = await fetch(`/api/search?q=${q}`)
      setResults(await res.json())
    }, 300)
  , [])

  return (
    <div>
      <input value={term} onChange={handleChange} />
      <ul>{results.map(p => <li key={p.id}>{p.name}</li>)}</ul>
    </div>
  )
}

The debounce prevented a flood of network calls, but each keystroke still triggered a state update, a re-render, and the creation of a new debounced function reference. With a product list of 40 000 items the render itself became noticeable.

Why debounce alone isn’t enough

Debounce works at the effect level, it postpones the side-effect (the fetch). The render side of the component still runs synchronously. React treats the state update (setTerm) as urgent; it batches it with other updates but still paints the UI before any lower-priority work finishes. If that lower-priority work includes a heavy computation, the UI thread stalls.

In older versions of React you could try requestIdleCallback or move the heavy logic to a Web Worker. Those approaches add complexity and often require a redesign of the data flow. With React 18 the library introduced a built-in way to mark work as non-urgent: useTransition.

Enter useTransition

useTransition returns a boolean isPending and a function startTransition. Anything wrapped in startTransition tells React that the update can be deferred until the browser is ready to paint. The UI stays responsive, and the user sees the cursor move instantly. The data fetching still happens, but it runs in a lower priority lane.

Here’s the same component refactored with useTransition:

import { useState, useTransition, useMemo } from 'react'
import _ from 'lodash'

function Search({ products }) {
  const [term, setTerm] = useState('')
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()

  const fetchResults = async q => {
    const res = await fetch(`/api/search?q=${q}`)
    setResults(await res.json())
  }

  const debouncedFetch = useMemo(() =>
    _.debounce(q => startTransition(() => fetchResults(q)), 300)
  , [startTransition])

  const handleChange = e => {
    setTerm(e.target.value)
    debouncedFetch(e.target.value)
  }

  return (
    <div>
      <input value={term} onChange={handleChange} />
      {isPending && <span>Loading…</span>}
      <ul>{results.map(p => <li key={p.id}>{p.name}</li>)}</ul>
    </div>
  )
}

What changed?

  • useTransition is imported and called once; it gives us isPending and startTransition.
  • The debounced function now wraps the fetch inside startTransition. This marks the fetch-related state update (setResults) as low priority.
  • The UI shows a tiny "Loading…" hint when the transition is in flight, but the input remains buttery smooth.

Practical warning: don’t over-transition

startTransition only defers state updates that are passed to it. If you accidentally put the setTerm call inside the transition, the input itself becomes low priority and you’ll see exactly the lag you tried to fix. Keep the UI-critical updates (the controlled input value) outside the transition.

When to prefer debounce over useTransition

Debounce still has a place. If your API has rate limits or you want to reduce server load, debouncing the network call is wise. useTransition does not throttle; it only deprioritises the render. In a scenario where the same query is typed repeatedly, you’d combine both: debounce the fetch and mark the fetch’s state update as a transition.

Measuring the improvement

I used Chrome’s Performance panel before and after the change. The "Main" thread showed a long task of ~30 ms for each keystroke when only debouncing. After adding useTransition the long task shrank to ~5 ms, and the input’s paint events were uninterrupted. The visual difference is subtle but noticeable on slower devices.

Edge cases and fallback

  • React version: useTransition requires React 18+. If you’re on an older version, you can polyfill the idea with unstable_ConcurrentMode or manually schedule work with requestIdleCallback.
  • Server-side rendering: Transitions only affect client rendering. The initial SSR payload remains unchanged, so SEO is unaffected.
  • Multiple concurrent transitions: React batches them, but if you start many heavy fetches at once you may still see a delay. In that case, consider pagination or limiting the result set.

A minimal alternative without useTransition

If you cannot upgrade, the next best thing is to move the heavy filtering to a memoised selector that runs only when the data changes, not on every keystroke:

const filtered = useMemo(() => {
  return products.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))
}, [products, term])

This keeps the render cheap, but it still runs synchronously. The UI will feel smoother than the original version, yet not as fluid as a true transition.

TL;DR

  • Debounce stops network storms, not render stalls.
  • useTransition tells React to treat the fetch-related state update as low priority, keeping the input responsive.
  • Keep the controlled input update outside the transition; otherwise you defeat the purpose.
  • Combine both techniques for the best of rate-limiting and UI smoothness.

FAQ

Why does my search input still lag after adding debounce?

Debounce only delays the API call. The component still re-renders on every keystroke, and if that render does any heavy work (filtering large arrays, building query strings) the main thread is blocked, causing lag.

Can I use useTransition with class components?

No. useTransition is a hook and only works inside functional components. For class components you would need to lift the state to a functional wrapper or migrate to hooks.

Does useTransition affect SEO or server-side rendering?

No. Transitions only influence client-side rendering. The HTML generated on the server remains the same, so crawlers see the same markup.

Wrapping up

If you ever get a call about a search box that feels like it’s dragging its feet, check where the render work is happening. A quick useTransition around the fetch-related state update often restores that snappy feel without sacrificing the debounced network traffic you already have. It’s a tiny change that makes a big difference for users on slower devices or with massive data sets.