TL;DR: A friend told me that typing felt laggy in Museek, my free multiplayer music quiz. I was sure the culprit was my autocomplete filtering. The profiler disagreed. The real problem was a progress bar animating its width once per second, plus a full-screen backdrop-filter: blur(). Switching the bar to transform cut background Rendering time by roughly 7x and took keystrokes out of the slow interactions list.

The bug report

Museek is a browser-based "blind test": up to 12 players each bring a Spotify, Deezer or Apple Music playlist, a song snippet plays, and everyone types the artist and title as fast as they can. It's built with Next.js 16, React 19 and Tailwind 4, with a Node/Socket.io backend.

A friend playing a game sent me this: "There's a delay between what I type and what shows up in the answer field."

In a game where you're racing a 30-second timer, that's not a cosmetic issue.

My first guess (and why it was wrong)

Each answer field has an autocomplete. On every render, my Game component filtered the whole song and artist database, and called a fairly expensive normalizeString() (Unicode NFD normalization plus two regexes) twice per item. Worse, it normalized the user's input again for every single item, inside the loop:

const filteredArtists = (database_artists || []) .filter((a) => { if (!artistQuery || artistQuery.length < 3) return false; const search = normalizeString(artistQuery); // once per item! return normalizeString(a.artist).includes(search) || normalizeString(a.internationalArtist).includes(search); }) .slice(0, 5); 

A quick benchmark gave about 18 ms per render with 2,000 artists and 2,000 tracks on my PC. That's enough to hurt on a phone, so I fixed the cheap part straight away: normalize the input once per render, and skip the loop entirely below 3 characters.

const artistSearch = normalizeString(artistQuery); const filteredArtists = artistQuery.length < 3 ? [] : (database_artists || []).filter(/* ...uses artistSearch... */).slice(0, 5); 

But one detail didn't fit. With the CPU throttled in DevTools, the lag started from the very first letter, and the filter does nothing below 3 characters. Something else was going on, so I stopped guessing and opened the Performance panel.

Step 1: measure properly (and beware of your extensions)

My setup: Chrome DevTools → Performance, CPU throttling at 4x (roughly a mid-range phone), record, type about ten letters, stop.

First result: INP 510 ms. Terrible. But the call stack under the slow keystroke looked odd: onCommitFiberRoot, handleCommitFiberRoot, then a long chain of updateFiberRecursively. And the "Forced reflow" insight pointed at measureHostInstance @ installHook.js.

Chrome DevTools trace: React DevTools extension causing 191 ms of forced reflow

installHook.js is injected by the React Developer Tools extension. After every React commit, it walks the component tree and measures elements on screen, which forces a layout. On that keystroke, Museek's own code accounted for 12.8 ms. The extension was responsible for most of the 191 ms of reflow.

Lesson #1: profile in an incognito window, where extensions are disabled by default. Your users don't run React DevTools.

Step 2: the real numbers

Same test, incognito window, 4x CPU, on the live site:

  • INP: 331 ms
  • INP breakdown: input delay 3 ms, processing 297 ms, presentation delay 31 ms
  • Inside that processing time: a single purple Layout of ~290 ms, triggered by Event: textInput
  • Scripting for the whole interaction: about 30 ms

Performance panel: INP 331 ms with continuous width animations on the timer bar

So JavaScript wasn't the bottleneck. Layout was. And the summary showed the page was busy even when nobody typed: 2,217 ms of Rendering and 954 ms of Painting over an 11.6-second recording. That's more than a quarter of the time spent laying out and painting.

Then the Animations track: a continuous chain of width segments, one per second.

Step 3: find the pattern

Zooming in on the slow keystroke, the timing was suspicious. It landed exactly at the start of a new width segment, right after a tick of the game clock.

Slow keystroke lining up with the start of a width animation on the timer bar

The animated element was my timer bar:

<div className="h-full rounded-full transition-all duration-1000 ease-linear ..." style={{ width: `${((totalPhaseTime - timeLeft) / totalPhaseTime) * 100}%` }} /> 

Every second, timeLeft changes. That one state update does three things at once:

  1. it updates the "Time left" text under the bar,
  2. it updates the same text in the header of the open answer field,
  3. it starts a new 1-second width transition on the bar.

All three invalidate layout. Animating width is especially bad: the browser has to recompute layout on every frame of the transition. If a keystroke arrives right at that moment, the browser has to finish all that layout work before it can paint the new letter. Keystrokes between two ticks stayed fast, which matches what my friend felt: not constant lag, but stutters.

One surprise: only 10 of 83 nodes needed layout, and it still took 262 ms on one capture. The node count wasn't the issue. The keystroke simply had to pay for the layout that the clock tick had just invalidated.

The fix: transform instead of width

transform and opacity are the two properties browsers can animate on the compositor (the GPU), without running layout. So the bar now always has full width and slides in from the left with translateX, inside a container that has overflow: hidden:

<div className="h-2 w-full overflow-hidden rounded-full bg-(--white)/20"> <div className="h-full w-full rounded-full transition-transform duration-1000 ease-linear will-change-transform ..." style={{ transform: `translateX(-${(timeLeft / totalPhaseTime) * 100}%)` }} /> </div> 

A few details:

  • Why translateX and not scaleX? scaleX would squash the rounded ends when the bar is short. Translating a full-width bar inside a clipped container looks identical and keeps the corners round.
  • transition-all → transition-transform. transition-all watches every property, so any change can trigger an expensive transition. Only transition what you actually animate. (Small trade-off: the bar's color now switches instantly between phases instead of fading.)
  • will-change-transform promotes the bar to its own compositor layer.
  • tabular-nums on the countdown text. With proportional digits, "10s" and "9s" have different widths, so the text shifts every second. Tabular digits all have the same width, so nothing around them moves.

Result

Same test, production build (run locally this time), incognito, 4x CPU. The Animations track now says transform instead of width.

After the fix: transform animations and short keystroke interactions

Over the recording Before (11.6 s) After (7.8 s)
Rendering 2,217 ms (19%) 213 ms (2.7%)
Painting 954 ms (8.2%) 71 ms (0.9%)

Relative to recording length, that's about 7x less Rendering and 9x less Painting. Most importantly, keystrokes disappeared from the slow interactions. The INP was still 368 ms, but the worst interaction was now a click, not a keystroke.

Bonus round: the full-screen blur

That slow click was the one that opens the answer field. Its breakdown was very different:

  • processing: 13 ms
  • presentation delay: 353 ms

INP breakdown of a click: 13 ms processing, 353 ms presentation delay

My code reacted instantly. The browser took 353 ms to show the result. When the field opens, a full-screen overlay appears behind it with backdrop-blur-sm. Blurring the whole viewport (including an animated timer bar and an SVG noise background) is expensive GPU work, and it doesn't show up as JavaScript or Layout on the main thread.

I removed the blur and made the background slightly more opaque to keep the dimming effect (/60 → /80). The click's presentation delay dropped to 237 ms, with 1 ms of processing.

Same click without backdrop blur: 237 ms presentation delay

One last tweak: the overlay was mounted conditionally ({isActive && <div ... />}), so every opening forced the browser to create and paint a brand new full-screen layer. Now it's always in the DOM, and only its opacity changes:

<div aria-hidden="true" className={`fixed inset-0 z-40 bg-(--background)/80 transition-opacity duration-250 ease-out ${ isActive ? "opacity-100" : "pointer-events-none opacity-0" }`} /> 

After that, only the very first opening of the field is still slow (about 270 ms at 4x, a one-time cost to create the layer). Later openings no longer show up as long interactions, and you get a fade-out on close for free.

What I'm taking away from this

  1. Profile in incognito. React DevTools alone turned a 331 ms problem into a 510 ms one and pointed me at the wrong function.
  2. Read the INP breakdown before touching code. High processing means your code (or forced layout). High presentation delay means rendering and GPU work. They need completely different fixes.
  3. A timer running while the user types can cost more than your JavaScript. Animate transform and opacity, never width, and avoid transition-all.
  4. Full-screen backdrop-filter isn't free, especially on low-end phones and on top of other animations.
  5. Your first hypothesis is just a hypothesis. My autocomplete fix was still worth keeping, but it wasn't the bug my friend was hitting.

The changes are in two small commits in the Museek repository: dfddfd7 and 02a0f42.

If you want to check that typing is smooth now, start a game on Museek: it's free, there's no account, and you can play with friends using your own playlists. You can also read the full case study on my portfolio.