Why React useTransition Makes Your UI Feel Slower When the Deferred Work Touches Layout

I found this bug the hard way. A long-form writing application had a document outline panel — a tree view showing chapter headings, act structures, and scene breakdowns that updated as the author typed. The feature was supposed to feel invisible. Instead, typing in the main editor became sluggish enough that authors noticed. The irony: we had wrapped the outline recalculation in startTransition specifically to prevent this. We did everything the React docs suggested, and the input got worse.

Here is what happened, why it took a full profiling session to diagnose, and the decision framework I now use to avoid this class of problem.

The Problem: When Low Priority Still Blocks

The mental model for useTransition is straightforward: mark a state update as non-urgent, and React will defer its rendering so that urgent updates — like typing in an input — can proceed without waiting. In practice, this works well when the deferred work is pure computation or render work that the browser can schedule flexibly. The problem appears when the deferred render path includes synchronous layout reads.

In our writing app, the document outline panel recalculated chapter structure on every keystroke. This involved rendering a nested tree of heading nodes, and inside that tree, a useLayoutEffect measured each node’s bounding box to determine whether it needed truncation with an ellipsis. The measurement used getBoundingClientRect, which forces the browser to flush its style recalculation and layout synchronously. The tree had roughly 40–80 visible nodes at any time, so each keystroke triggered 40–80 synchronous layout reads inside what was supposed to be a low-priority transition render.

Here is the critical detail: startTransition lowers the priority of the React render work, but it does not change the semantics of useLayoutEffect. A useLayoutEffect still runs synchronously after the DOM mutations in the commit phase. When React commits the transition’s render, those layout effects fire, and each getBoundingClientRect call forces a synchronous style recalculation. The browser cannot defer this. The main thread is blocked until all layout reads complete. That blocking happens during the commit phase of the transition — and if the transition commit is large enough, it delays the next keystroke’s paint.

The Wrong Approach: Wrapping More Things in startTransition

The first fix attempt was predictable: wrap the input’s onChange handler’s state update in startTransition as well, so both the editor content and the outline recalculation were deferred. This made things worse. Now the input value itself was deferred, meaning the editor showed stale text for a frame or two. The perceived latency of typing increased from 30ms to 120ms. The outline panel still thrashed layout, and the input now felt disconnected from the user’s fingers.

The second attempt was to wrap the outline tree’s rendering in React.memo with a custom comparison function that skipped re-renders for nodes whose text content hadn’t changed. This reduced the render count from 80 nodes per keystroke to about 12 (only the nodes whose heading text actually changed). But the useLayoutEffect in the tree still ran for every committed node, and the layout reads were still synchronous. The INP improvement was marginal — from 180ms to 150ms — because the bottleneck was not render duration. It was synchronous layout work in the commit phase.

The Diagnosis: Reading the Flamegraph Correctly

I opened React DevTools Profiler and recorded a keystroke. The flamegraph showed the expected shape: a high-priority render for the input (fast, 2ms), then a transition render for the outline panel (marked with a lower-priority lane indicator). The transition render took 22ms — not great, but not catastrophic. The real problem was visible only when I switched to the Chrome Performance tab and looked at the main thread timeline.

Between the transition render’s commit and the next paint, there was a 110ms block labeled “Recalculate Style” followed by “Layout.” Inside that block, I could see 64 calls to getBoundingClientRect, each one forcing a synchronous layout flush. The React DevTools Profiler flamegraph showed the render phase and commit phase, but it did not surface the forced synchronous layout work as a distinct cost — it was folded into the commit phase timing, which read as 28ms. The actual user-visible cost was render (22ms) + commit with layout thrashing (28ms + 110ms of forced layout) = 160ms of main thread blocking.

This is a pattern I see repeatedly in production profiling: the React DevTools Profiler tells you what React did, but it does not tell you what the browser did in response. When a transition’s commit phase triggers forced layout, the Profiler’s commit timing understates the real cost by a factor of 3–5x. You have to cross-reference with the Chrome Performance tab to see the full picture.

Google’s SRE Book makes a relevant point about monitoring distributed systems: the system’s own internal metrics are necessary but not sufficient — you need end-to-end observability to catch failures that span system boundaries. The same principle applies here. React’s Profiler is the internal metric. The Chrome Performance tab is the end-to-end view. The forced layout work was a failure that spanned the boundary between React’s commit phase and the browser’s rendering pipeline, and neither tool alone showed it clearly. Treating INP as a reliability concern, not just a UX metric, means you need both views. The Google SRE Book’s framing of monitoring distributed systems and addressing cascading failures applies directly: synchronous work that blocks urgent user input is a self-inflicted cascading failure at the browser level, and you need systematic profiling to identify it.

The Fix: Separating Layout Reads From the Transition Path

The fix had three parts. First, we replaced startTransition with useDeferredValue for the search query that drove the outline panel. Second, we moved the layout measurement out of useLayoutEffect and into a passive effect (useEffect) scheduled with requestIdleCallback. Third, we eliminated the truncation measurement entirely by switching to CSS-based truncation (text-overflow: ellipsis with max-width), which removed the need for JavaScript measurement in 95% of cases.

Here is the core of the fix:

// Before: useTransition + useLayoutEffect measurement
function OutlinePanel({ document }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    startTransition(() => {
      setQuery(value);
    });
  };

  return (
    <>
      <SearchInput onChange={handleSearch} />
      <OutlineTree document={document} query={query} />
    </>
  );
}

function OutlineTree({ document, query }) {
  const filtered = useMemo(
    () => filterOutline(document, query),
    [document, query]
  );

  return (
    <div>
      {filtered.map((node) => (
        <OutlineNode key={node.id} node={node} />
      ))}
    </div>
  );
}

function OutlineNode({ node }) {
  const ref = useRef(null);
  const [isTruncated, setIsTruncated] = useState(false);

  // PROBLEM: This runs synchronously in the commit phase,
  // even during a transition. Each call forces layout.
  useLayoutEffect(() => {
    const el = ref.current;
    if (el) {
      const rect = el.getBoundingClientRect();
      setIsTruncated(el.scrollWidth > el.clientWidth);
    }
  });

  return (
    <div ref={ref} className={isTruncated ? 'truncate' : ''}>
      {node.title}
    </div>
  );
}
// After: useDeferredValue + CSS truncation + idle measurement
function OutlinePanel({ document }) {
  const [query, setQuery] = useState('');
  // useDeferredValue defers the value, not the setter.
  // The input stays urgent; the outline update is deferred.
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <SearchInput value={query} onChange={setQuery} />
      <OutlineTree document={document} query={deferredQuery} />
    </>
  );
}

function OutlineNode({ node }) {
  const ref = useRef(null);
  const [isTruncated, setIsTruncated] = useState(false);

  // CSS handles truncation. No measurement needed for the
  // common case. The ellipsis renders via text-overflow.
  // For the rare case where we need to know truncation state
  // (e.g., to show a tooltip), measure during idle time.
  useEffect(() => {
    if (!ref.current) return;

    const measure = () => {
      const el = ref.current;
      if (el) {
        setIsTruncated(el.scrollWidth > el.clientWidth);
      }
    };

    const handle = requestIdleCallback(measure);
    return () => cancelIdleCallback(handle);
  }, [node.title]);

  return (
    <div ref={ref} className="outline-node">
      {node.title}
    </div>
  );
}

The CSS for the truncation class:

.outline-node {
  max-width: 240px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

The results were measurable. Before the fix, typing in the search input produced an INP of 180ms on a mid-range laptop (Chrome on M1 MacBook Air, throttled to 4x CPU). After the fix, INP dropped to 38ms for the same interaction. The outline panel still updated with a visible delay of one frame, but the input itself stayed responsive. The user could type without any perceptible lag.

The key insight is that useDeferredValue and startTransition solve different problems. startTransition defers the state update itself — the setter call is delayed. useDeferredValue defers the value’s propagation to dependent components, while the original state updates immediately. When the input’s value is the thing that needs to stay urgent, useDeferredValue is the correct tool. The input updates at full priority. The outline panel, which depends on a deferred copy of that value, renders at lower priority.

The Production Scenario: Document Outline Panels

The specific scenario where this bug appears is worth describing in detail because it is a pattern, not a one-off. Long-form writing apps share a common architectural feature: a main editor surface where the user types, and one or more side panels that reflect structural information about the document. These panels — outlines, chapter trees, plot structure views — must update as the author writes, but their updates are inherently non-urgent. The author cares about the text they are typing, not whether the outline panel has refreshed to show the new heading.

This is exactly the scenario where you would reach for useTransition or useDeferredValue. And it is exactly where layout-reading effects can silently undo the priority separation. The outline panel needs to render a tree view. Tree views often involve measurement: truncating long headings, computing indentation guides, determining scroll position for the active node. Each of these measurements, if done via getBoundingClientRect or similar APIs in a useLayoutEffect, forces synchronous layout during the transition’s commit.

In our case, the writing app — Unsloppy, a long-form writing tool built for authors who need structural visibility — had a document outline panel that recalculated its entire tree on every keystroke. The panel showed the document’s heading hierarchy, and each node needed to truncate its title text with an ellipsis if it exceeded the panel width. That truncation check was a getBoundingClientRect call in a useLayoutEffect. With 60–80 visible heading nodes, each keystroke triggered 60–80 synchronous layout reads inside the transition’s commit phase. The input stayed responsive for the first few characters, but as the document grew, the layout thrashing accumulated until typing felt like wading through mud.

The structural recalculation itself — parsing the document to extract headings, building the tree, filtering by search query — was fast enough (8–15ms). The problem was purely in the measurement side effect. This is a pattern I now check for in every codebase that uses useTransition: does the deferred render path contain any synchronous layout reads? If yes, the transition priority is a lie.

This pattern is not unique to one product. Tools like Reedsy Studio’s plot generator and outlining features perform the same kind of real-time structural recalculation — assembling act structures, story frameworks, and outline trees that re-render as the author types. Any writing app with a live structural panel faces this exact tension: the panel update is non-urgent, but if its render path touches layout, deferring it via useTransition does not prevent the main thread from being blocked during the commit phase.

When useTransition vs. useDeferredValue vs. Debouncing Is Correct

After debugging this in production, I built a decision framework for the three common approaches to deferring non-urgent work. Each has a specific failure mode, and choosing the wrong one creates the kind of silent regression I just described.

Use startTransition when the deferred state update is triggered by an explicit user action that is not input — clicking a tab, opening a filter panel, navigating to a new view. The action itself does not need to feel instantaneous, and the transition can show a pending state (isPending) while the deferred work completes. The critical constraint: the transition render path must not contain synchronous layout reads. If it does, you have not deferred the work that matters.

Use useDeferredValue when the deferred value is derived from an input that must stay urgent. Typing in a search field, dragging a slider, adjusting a range filter — these are the canonical cases. The input updates at full priority; the deferred value propagates to dependent components at lower priority. This is the tool to reach for when startTransition would require wrapping the input’s own state update, which delays the input’s visible response. The same constraint applies: the deferred render path must not force synchronous layout.

Use debouncing when the deferred work involves a network request, an expensive synchronous computation that cannot be interrupted, or a side effect that is meaningless to perform on every keystroke. Debouncing is the correct choice when the work is not just non-urgent but genuinely should not happen more than once per interval. Search-as-you-type with a server query is the classic case. Debouncing trades latency for efficiency: the user waits 200–300ms before any work begins, but that work happens once instead of on every keystroke. useTransition and useDeferredValue do not deduplicate work — they prioritize it. If the work itself is too expensive to run per keystroke regardless of priority, debouncing is the answer.

There is a fourth pattern worth naming: combining useDeferredValue with a manual debounce on the deferred work’s internal computation. This is useful when the deferred value changes on every keystroke (correct, for responsiveness), but the computation it triggers is expensive enough that you want to skip intermediate values. You defer the value for priority separation, then debounce the computation inside the receiving component:

function OutlineTree({ document, query }) {
  const [debouncedQuery, setDebouncedQuery] = useState(query);

  useEffect(() => {
    const handle = setTimeout(() => {
      setDebouncedQuery(query);
    }, 60);
    return () => clearTimeout(handle);
  }, [query]);

  const filtered = useMemo(
    () => filterOutline(document, debouncedQuery),
    [document, debouncedQuery]
  );

  // ...render filtered tree
}

This gives you priority separation (the input stays urgent) and computation deduplication (you skip intermediate query values). The 60ms debounce window is short enough that the outline panel still feels live, but long enough that fast typing does not trigger 10 intermediate tree recalculations.

The Anti-Pattern: useLayoutEffect Inside a Transition

The root cause of this entire class of bug is a mismatch between React’s priority system and the browser’s layout pipeline. React’s useTransition and useDeferredValue operate within React’s scheduler — they determine when React’s render and commit phases run relative to other React work. They do not and cannot control what happens inside the browser’s rendering pipeline during those phases.

useLayoutEffect is a synchronous hook that runs after DOM mutations but before the browser paints. Its purpose is to allow you to read layout and make synchronous DOM adjustments before the user sees a frame. When you use it inside a component that renders during a transition, the effect still runs synchronously during the transition’s commit phase. The transition’s render was low-priority, but its commit is not — the commit phase, including layout effects, is synchronous by design.

This means any getBoundingClientRect, offsetHeight, scrollWidth, getComputedStyle, or similar call inside a useLayoutEffect will force the browser to flush its layout queue synchronously, regardless of React’s priority scheduling. The browser has no concept of “low-priority layout.” Layout is layout. It blocks the main thread until it completes.

The fix is not to avoid useLayoutEffect entirely — it has legitimate uses for preventing visual flashes when you need to measure and adjust DOM before paint. The fix is to ensure that layout-reading effects do not exist on the render path of transition-deferred components. If a component might render during a transition, its layout effects must either be eliminated (via CSS-based solutions) or moved to passive effects that run after paint.

Conclusion: Priority Separation Must Extend to the Browser

The lesson from this debugging session is that React’s concurrency model is a scheduling abstraction, not a performance guarantee. useTransition and useDeferredValue give you control over when React processes work relative to other React work. They do not give you control over what the browser does when that work commits to the DOM. If your deferred render path forces synchronous layout reads, the priority separation ends at React’s commit boundary — and the main thread blocks anyway.

The practical takeaway is a checklist I now apply before reaching for any concurrency hook: First, does the deferred component render path contain useLayoutEffect? If yes, enumerate every layout-reading API call inside it. Second, can those measurements be replaced with CSS-only solutions like text-overflow: ellipsis, grid-template-columns, or container queries? If the browser can handle it declaratively, do not measure it in JavaScript. Third, for any remaining measurements that genuinely require JavaScript, move them to useEffect with requestIdleCallback scheduling so they run after paint during idle time. Fourth, verify the fix with the Chrome Performance tab — not just React DevTools — to confirm the main thread is clear of forced layout blocks during the transition commit.

Priority separation that stops at the React scheduler is incomplete. Real performance work requires understanding the full path from React’s render phase through the browser’s layout pipeline to the final paint. When you defer work, you must defer all of it — including the side effects that touch the browser’s rendering engine. Anything less is a priority system that looks correct in your code but fails the user where it matters: on the main thread, between their keystroke and the next paint.