Denshin / Blog / Engineering
Fixing slow clicks in a React admin panel
Admin panels are rarely slow to load, they are slow to use. What INP measures, the React patterns that make clicks and keystrokes lag, and the fixes we reach for first.
Denshin Engineering · Engineering Team · 27 October 2026 · 5 min read
Admin panels are rarely slow to load. They are slow to use. The table appears in under a second, and then typing into the filter box drops letters, the status dropdown takes a beat to open, and clicking a row feels like pressing a lift button that might not be connected to anything. Users do not file bugs about this. They just start describing the system as "heavy".
The metric that captures this is Interaction to Next Paint, INP. It replaced First Input Delay as a Core Web Vital in March 2024. Where FID only measured the delay before the browser started handling your first interaction, INP looks at interactions across the whole visit and reports roughly the worst one: the time from a click, tap or key press to the next frame the user actually sees. Google treats 200 ms or less at the 75th percentile as good and anything above 500 ms as poor.
Public pages usually get the performance attention because they affect search. Admin panels get none, which is odd, because they are where staff spend most of their working day. These are the causes we find most often in React admin screens, roughly in order of how often they turn up.
Every keystroke re-renders the whole table
The classic. A filter input stores its value in state at the top of the page, the table reads the filtered list, and so every key press re-renders a few hundred rows, each with badges, dates formatted through a library, and a dropdown menu. The input cannot paint the new character until all of that finishes.
The fix is to separate what must be instant (the text in the box) from what can lag a little (the filtered table). React gives you useDeferredValue for exactly this:
const [query, setQuery] = useState("")
const deferredQuery = useDeferredValue(query)
const rows = useMemo(
() => filterRows(allRows, deferredQuery),
[allRows, deferredQuery],
)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<OrdersTable rows={rows} />
</>
)
The input updates immediately, and React renders the table with the deferred value in the background, throwing away stale renders if you keep typing. For this to help, the table has to actually skip work when its props have not changed, so wrap the row component in memo and make sure you are not handing every row a fresh inline object or callback on every render. That last part is where most attempts quietly fail.
If filtering happens on the server, debounce the request by 250 ms or so and never make the input wait on the network. A small "updating" hint on the table is fine. Swallowing keystrokes is not.
Long lists rendered in full
A page that renders 2,000 rows because "pagination felt clunky" will be slow to interact with even when nothing changes, because the browser has a huge DOM to style and lay out on every update. Either paginate on the server or virtualise the list so only the visible rows exist in the DOM. Virtualisation libraries are mature and adding one to a plain table is an afternoon's work. The usual catches are sticky headers and variable row heights, so test those first.
Heavy work inside the event handler
Sometimes the click handler itself does too much: sorting a big array, building an export, recalculating totals across every row. All of that runs before the browser can paint the response to the click. Two techniques help. First, update the visible state first (close the menu, show the spinner) and push the expensive work out of the current task. Second, break long work into chunks and yield to the main thread between them:
function yieldToMain() {
if ("scheduler" in window && "yield" in window.scheduler) {
return window.scheduler.yield()
}
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function recalcTotals(rows) {
for (let i = 0; i < rows.length; i += 500) {
processChunk(rows.slice(i, i + 500))
await yieldToMain()
}
}
scheduler.yield() is not in every browser yet, hence the fallback. For state updates that trigger expensive renders, startTransition does a similar job inside React: the update is marked as interruptible, so a new click or key press gets handled first.
Finding the culprit
Guessing is slow. The Long Animation Frames API tells you which frames ran long (over 50 ms) and which scripts ran during them, which is far more useful than the older long tasks API when you are trying to pin a slow interaction on a specific handler:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 100) {
console.log(entry.duration, entry.scripts.map((s) => s.sourceURL))
}
}
}).observe({ type: "long-animation-frame", buffered: true })
Drop that into a development build, click around the slow screen, and the offenders usually announce themselves. The Performance panel in Chrome DevTools also shows INP for live interactions, which is enough for quick spot checks.
The other half is testing on the right hardware. Developer laptops hide all of this. The people using the admin panel are often on a five-year-old office desktop or a budget Android phone, and a screen that feels instant on a new MacBook can take half a second per click on those. Turn on CPU throttling in DevTools (4x or 6x) as a habit, and keep one cheap phone in the office for real checks. We do the same for public pages, which is part of why our homepage tiers its animations by device.
None of these fixes is exotic. What makes the difference is treating "the click felt slow" as a bug with a number attached, rather than a vague feeling about a screen. Once INP is something you measure on the admin side too, it stops creeping upward every time someone adds a column to the table.
Tags: React, Performance, INP, Core Web Vitals, Admin Panels
All posts · Work with Denshin