Scenario Debugging
“The API Is Fast but the UI Is Slow”: How to Solve It
The network tab says 80 ms, but users wait two seconds. Long main-thread tasks, huge JSON, many re-renders, layout thrashing and hydration cost, all linked to INP.
On this page
- Confirm the diagnosis
- Suspect 1: rendering far too many DOM nodes
- Suspect 2: parsing and processing a huge payload
- Suspect 3: re-render storms
- Suspect 4: expensive work during render
- Suspect 5: layout thrashing and forced reflows
- Suspect 6: hydration cost
- Suspect 7: client-side waterfalls
- Suspect 8: third-party scripts and other main-thread hogs
- startTransition and useDeferredValue
- Scenario: a 5 MB JSON feeds a table. Where is the time going?
- A checklist you can recite
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: if the API answers quickly but the screen is slow, the time is spent on the main thread. The main thread is the one worker in the browser that runs your JavaScript and also draws the page. It may be busy parsing a huge response, building too many DOM nodes (the DOM is the browser's tree of page elements, and each element is a node), re-rendering too often (React runs a component again to work out what the screen should show), or running long tasks (any piece of JavaScript that blocks the main thread for more than 50 ms). Find which one in the Performance panel. Then do less work, or move the work out of the way.
This is the most common senior-level frontend scenario. It connects directly to the INP metric (Interaction to Next Paint: how long the page takes to react after a click or key press). See Browser Contract, Lesson 09.
Think of the main thread as a single cashier in a shop. If the cashier is busy with one huge order, every other customer waits, even if the warehouse (the server) already delivered the goods.
Confirm the diagnosis
- Network tab: the request's Waiting (TTFB) is small (for example 80 ms). TTFB means "time to first byte", the time until the server starts answering. Content Download is reasonable too. So the server is not the problem.
- Performance panel (with 4x CPU throttling, which makes your computer act slower, like a weaker phone): record the interaction. Look for:
- Long tasks (more than 50 ms, marked in red in the Main thread track).
- A large Scripting (yellow) block after the response arrives.
- Recalculate Style / Layout (purple) and Paint / Commit (green) blocks.
- Break the delay into parts. This is the INP model:
- Input delay: the main thread was busy before your handler could start.
- Processing time: your handlers and the work they cause.
- Presentation delay: the render and paint after the handlers finish.
Now look at the usual causes. They are listed roughly from most common to least common.
Suspect 1: rendering far too many DOM nodes
JSON is a text format for data (for example {"name": "Ana"}). A 5 MB JSON file feeding a table of 20,000 rows can create hundreds of thousands of DOM nodes. The browser must create, style, lay out and paint all of them. This usually takes much more time than parsing the JSON.
How to see it: you get a huge Scripting + Layout + Paint cost after the response. The Elements panel shows a very large DOM. document.querySelectorAll("*").length is in the tens of thousands.
Fixes, from most to least effective:
- Don't send or render what the user cannot see. Use server-side pagination (send one page of results at a time) or cursor paging (the client asks for "the next items after this last one"). Apply filters on the server. See Practical Design Problems, Lesson 03.
- Virtualise long lists. Virtualisation (also called windowing) keeps only the rows visible on screen in the DOM and swaps them as the user scrolls. See Lesson 07.
- Use
content-visibility: auto. This CSS property tells the browser to skip layout and painting for sections that are off-screen, until the user scrolls near them. Addcontain-intrinsic-size(a guess of the section's size), or the scrollbar will jump. - Make each row simpler: fewer wrapper elements and fewer nested components.
Suspect 2: parsing and processing a huge payload
Parsing means turning text into JavaScript objects. It is synchronous (it blocks everything else) and uses a lot of CPU. JSON.parse, the built-in function that does this for JSON, usually takes tens of milliseconds for 5 MB on a desktop (sometimes about 100 ms). On a mid-range phone it can be several times slower. The work after parsing (mapping, sorting and cleaning 50,000 objects) often costs more than the parse itself.
Fixes:
- Send less. Return only the fields the screen needs (
selectin your database query, sparse fieldsets in JSON:API, or the fields you ask for in a GraphQL query). Drop unused data. Compress with Brotli (a compression format that is usually smaller than gzip for text). Paginate. - Do heavy changes to the data on the server, or in a separate endpoint that prepares the result in advance.
- Move heavy parsing and processing to a Web Worker. A Web Worker is a browser feature that runs a JavaScript file on a separate background thread, so it cannot block the main thread. It has no access to the DOM, and the two threads talk by sending messages. Send only the final result back to the main thread: the data you pass with a message is copied (structured clone), so copying a huge result back can cost almost as much as the work you moved.
- Stream NDJSON (newline-delimited JSON: one JSON object per line) and show the rows step by step. Do not wait for one giant blob.
- Avoid repeated deep copies of large state on every update. A deep copy makes a full new copy of an object and everything inside it. Examples are
structuredCloneandJSON.parse(JSON.stringify(x)).
// worker.ts — the work happens off the main threadself.onmessage = (e: MessageEvent<{ rows: Row[]; query: string }>) => { const { rows, query } = e.data; const result = rows.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())).sort(byDateDesc); (self as unknown as Worker).postMessage(result.slice(0, 200)); // send back only what will be shown};Suspect 3: re-render storms
A "re-render storm" is when one state change makes a huge part of the component tree render again, and this happens again and again. For example, typing in a search box re-renders the whole dashboard on every keystroke. Or a context value changes and every component that reads it updates.
How to see it: the React Profiler shows many components rendering on each commit (a commit is one batch of updates that React applies). "Highlight updates when components render" flashes everywhere. The same interaction causes several commits.
The fixes are the topic of Lesson 03. In short:
- Colocate state: keep state in the component that uses it, not high up in the tree.
- Split big components into smaller ones.
- Memoise the expensive children. Memoizing means remembering the result of work so it is not repeated.
memois a React function that makes a component skip rendering when its props are the same as last time. Give the children stable props (props that are the same object in memory between renders). - Split contexts, or use selectors. A context is React's way to share a value with many components without passing props. A selector is a small function that picks one piece of a store, so a component re-renders only when that piece changes.
- Virtualise long lists.
Suspect 4: expensive work during render
function Table({ rows, query, sortKey }: Props) { // ❌ Runs on EVERY render, even if rows/query/sortKey did not change (e.g. a parent re-render) const visible = rows.filter((r) => match(r, query)).sort(compare(sortKey)); return <>{visible.map((r) => <Row key={r.id} row={r} />)}</>;}Fixes:
- Use
useMemofor derived data (data worked out from other data).useMemois a React hook that memoizes (remembers) a value: it keeps the result of a calculation and only calculates again when its inputs change. Use it only when the calculation is really expensive and its inputs are stable. Measure first. - Move the work out of render. Calculate it once when the data arrives, or do it on the server.
- Use a better algorithm. For example, prepare a lowercase search field once. Do not lowercase every row on every keystroke. Use a
Mapfor lookups instead offindinside loops (that mistake makes the code slow in a hidden way: for n rows it does about n × n steps, written O(n²)). - Debounce (wait until the user pauses typing before you start the work) or defer the expensive part. See below.
Suspect 5: layout thrashing and forced reflows
"Layout" is the step where the browser works out the size and position of every element. If you read a layout value (offsetHeight, getBoundingClientRect()) right after changing a style, the browser must redo layout immediately. If this happens inside a loop or an effect, it repeats many times. This is called a forced reflow. See Browser Contract, Lesson 08.
In the Performance panel you see repeated Layout blocks with a "Forced reflow" warning.
Fix: do all the reads first, then all the writes. Measure once. Use ResizeObserver or IntersectionObserver. These are browser APIs that call your function when an element's size changes, or when it enters or leaves the screen, so you do not have to measure it yourself. Do not measure every row.
Suspect 6: hydration cost
Hydration is the step where React, in the browser, takes the HTML that the server already sent and makes it interactive. It runs your components again on the client and attaches the event handlers.
On a Next.js page, the HTML arrives fast and looks ready. But the buttons do nothing for a second or two, because React is still hydrating. The cost grows with the amount of client-side JavaScript and the size of the component tree.
How to see it: a long task right after the JS bundle loads. You may see "Hydrate" or large commitRoot work. Users who click early get no response (bad INP and "rage clicks", where people click again and again).
Fixes:
- Send less client JavaScript. Make components Server Components by default. A Server Component is a React component that runs only on the server and sends no JavaScript for itself to the browser. Mark only the interactive leaves with
"use client", a line at the top of a file that makes its components run in the browser. See Frontend Depth, Lesson 02. - Code-split heavy widgets that are not critical. Code splitting means cutting your JavaScript into several files that load separately. Use a dynamic import (
import(), which loads a file on demand), and load them after an interaction or when they become visible. - Use selective hydration with
<Suspense>boundaries.<Suspense>is a React component that shows a fallback (such as a spinner) while part of the page is not ready. Parts of the page then hydrate separately, and React hydrates first the part the user clicked on. - Do not render huge lists on the client during hydration.
Suspect 7: client-side waterfalls
A waterfall is a chain of requests where each one waits for the one before. The API is fast, but the page makes sequential calls. It renders, then fetches the user, then renders, then fetches their projects, then renders, then fetches project details. Each step waits for the last render. Ten calls of 80 ms become about a second of waiting, plus extra renders.
Fixes:
- Fetch in parallel with
Promise.all(a JavaScript function that starts several async calls together and waits for all of them) when the requests do not depend on each other. - Move the fetching up to the route or the server (Server Components, loaders). The data is then requested before the component tree renders.
- Prefetch requests that will probably come next (on link hover, or route prefetch). Prefetching means loading something before the user asks for it.
- Combine "chatty" endpoints. Make one endpoint for the page, or use a GraphQL query that returns the related data in one call. See Backend, Lesson 09.
Suspect 8: third-party scripts and other main-thread hogs
Third-party scripts are scripts from other companies that you add to your page. Analytics, tag managers, chat widgets, A/B testing and ad scripts all run on the same main thread. How to see it: long tasks that belong to a third-party domain. In the Performance panel, open the Bottom-Up tab and group by Domain, so the cost of each third-party domain is added up.
Fixes:
- Load scripts with
asyncordefer(two attributes that let the browser download a script without stopping the HTML parser; the script still runs on the main thread when it executes), or use the Next.jsnext/scriptstrategies (afterInteractive,lazyOnload). The experimentalworkerstrategy is not stable and does not yet work with the App Router. - Remove the scripts you do not need.
- Measure how much INP each tag costs.
- Also check image decoding (very large images), heavy CSS selectors, and animations that cause layout.
startTransition and useDeferredValue
startTransition is a React function that marks a state update as non-urgent. useDeferredValue is a React hook that gives you a copy of a value that is allowed to update a little later. These two React APIs do not make the work cheaper. They make it interruptible and lower priority, so typing and clicking stay responsive.
Everyday comparison: you are cooking, and the doorbell rings. Answering the door is urgent. Stirring the soup can wait a moment. React does the same thing with updates.
function Search({ rows }: { rows: Row[] }) { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); // lags behind during heavy renders return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} /> {/* urgent: updates immediately */} <ResultList rows={rows} query={deferredQuery} /> {/* non-urgent: re-renders when React has time */} </> );}const ResultList = memo(function ResultList({ rows, query }: { rows: Row[]; query: string }) { const visible = useMemo(() => rows.filter((r) => r.searchText.includes(query)), [rows, query]); return <ul>{visible.slice(0, 200).map((r) => <li key={r.id}>{r.name}</li>)}</ul>;});useDeferredValue(value)gives you a copy of the value that "catches up" a little later. While the heavyResultListrenders with the old value, the input stays fast. If the user types again, React can interrupt the deferred render.startTransition(() => setState(...))marks a state update as non-urgent. React can render it in the background without blocking urgent updates such as typing or clicking.- Requirement: wrap the slow child in
memo, so it does not re-render for the urgent update. Its render must also be interruptible. These tools do not help in two cases. The first is when the cost is one huge synchronous calculation outside React rendering. The second is when the DOM work in the commit phase is the problem, because the commit cannot be interrupted. - They improve INP because input is blocked less. But the total CPU work stays the same. Combine them with virtualisation and memoisation to reduce the work itself.
Scenario: a 5 MB JSON feeds a table. Where is the time going?
Here is a ranked analysis that you can say out loud:
| Stage | Typical cost | How to check |
|---|---|---|
| Download | Depends on size (is it compressed?) | Network tab "Content Download", transferred size vs resource size |
JSON.parse | Tens of ms (up to about 100 ms) on desktop, 5x or more on mid-range phones | Performance panel "Parse"/scripting just after the response |
| Building state / mapping 50k objects | 10s to 100s of ms | User timing marks around the code |
| React render of 50k rows | Often the largest: hundreds of ms to seconds | Profiler render duration, Scripting after parse |
| DOM creation, style, layout, paint | Also large with ~100k+ nodes | Performance panel purple/green blocks; DOM node count |
| Later interactions (sort/filter) re-render all rows again | Repeats the render cost on each action | Profiler on a sort click |
The plan:
- Send less. Paginate or filter on the server, and trim fields.
- Virtualise the list.
- Memoise rows. Move heavy sorting and filtering off the main thread, or to the server.
- Use
useDeferredValueso typing stays responsive. - After each step, make a new recording and compare INP and total blocking time (TBT, a lab metric: for every long task during page load, the time beyond 50 ms is counted, and the results are added up).
A checklist you can recite
- Confirm in the Network tab that TTFB is small. Record the Performance panel under CPU throttling.
- Find the biggest block: Scripting, Layout/Style, Paint, or long tasks.
- Count the DOM nodes and rendered rows. Reduce them with pagination or virtualisation.
- Check the payload size and what is parsed. Trim it, compress it, or move it to a worker.
- Open the React Profiler. What re-renders, why, and how often?
- Look for work inside render (filter/sort/map) and for forced reflows.
- Check hydration and client JS size. Move to Server Components and split code.
- Look for sequential client fetches and third-party scripts.
- Use transitions or deferred values for non-urgent updates.
- Measure again. Add a budget (a limit, such as a maximum bundle size) or a RUM alert (an alert based on real-user data).
Common mistakes
- Blaming the API because the page feels slow, without reading the Timing breakdown.
- Fixing JSON parsing when the real cost is rendering the rows.
- Wrapping everything in
useMemo/memowithout profiling. - Expecting
startTransitionto reduce work. It only changes when the work runs. - Profiling on a fast laptop with no CPU throttling.
- Fetch waterfalls inside nested components.
- Ignoring hydration cost on server-rendered pages.
Interview questions
What's your first step in DevTools?
I open the Network tab and check the Timing of the slow request. If TTFB is small, the server is fast. Then I record the Performance panel with CPU throttling and repeat the slow action. I look for long tasks, and I check whether Scripting, Layout or Paint takes the most time. I also use the React Profiler to see what rendered.
How do long tasks affect INP?
A long task blocks the main thread. If a click arrives during the task, it must wait. That wait is the input delay. If a handler starts a long render, the next paint is late. That is the processing and presentation delay. INP measures the whole time of the interaction, so frequent long tasks make it worse. To fix it, break tasks into smaller parts, delay work that is not urgent, and reduce the rendering cost.
Scenario: a 5 MB JSON response feeds a table. Where is the time going?
Mostly not in the download or in JSON.parse. The time goes into building state for tens of thousands of items. Above all, it goes into rendering and laying out a huge number of DOM nodes, and this repeats on every sort or filter. I would check this with the Performance panel and the Profiler. Then I would reduce the data (server pagination, fewer fields) and virtualise the list. I would also memoise rows, move heavy processing to a worker or the server, and defer updates that are not urgent.
How would startTransition or useDeferredValue help?
They mark an update as not urgent. React renders it in the background, and it can stop that work when urgent input arrives. So typing and clicking stay fast, and INP gets better. They do not reduce the total work. They also need the slow part of the tree to be memoised, and the cost must be in React rendering. They cannot help with one long synchronous calculation that React cannot interrupt.
The page looks loaded but buttons don't respond for two seconds. What's happening?
Probably hydration. The HTML from the server is visible. But the browser must download the client bundle, run it, and render the components again to attach the handlers. To fix it, I would send less client JavaScript with Server Components. I would code-split widgets that are not critical, use Suspense boundaries for selective hydration, and remove third-party scripts that are not needed.
How do you find out whether a slowdown is caused by rendering or by data processing?
I add User Timing marks (performance.mark/measure) around the processing step. Then I compare them with the React Profiler render times and with the Scripting and Layout/Paint blocks in the Performance panel. If processing is short and rendering and commit take most of the time, the fix is fewer DOM nodes and fewer re-renders. If processing takes most of the time, I make it faster or move it elsewhere.
What is a client-side waterfall and how do you avoid it?
It is a chain of dependent requests. Each fetch starts only after the data from the previous one has rendered, so the waiting times add up. To avoid it, fetch in parallel, move data loading to the server or route level, prefetch, and combine endpoints.
Key takeaways
- API fast, UI slow means main-thread work. Find it in the Performance panel and React Profiler under CPU throttling.
- The biggest causes are: too many DOM nodes, re-render storms, expensive work in render, big payload processing, hydration, waterfalls, and third-party scripts.
- First reduce the work: send less, render less, virtualise, memoise with stable props. Then change when the work runs: transitions, deferred values, workers.
- Check the result with INP and total blocking time, and keep a budget.
What's next
Lesson 02: "The UI Is Fast but the API Takes Too Long". This is the opposite case. The browser is idle and the server is slow. We follow a request through every backend layer.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Design Autocomplete / Typeahead (Frontend System Design) · Practical Design Problems
- React Rendering Model: Render, Commit, Reconciliation and Keys · Senior Frontend Depth
- Hooks in Depth: Effects, Refs and the Rules · Senior Frontend Depth
Part of the study guide
Related series