<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex Xiao</title>
    <description>The latest articles on DEV Community by Alex Xiao (@axyj).</description>
    <link>https://dev.to/axyj</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4239865%2Fdd9727cf-9c87-4cf7-8bec-e62f6a1e64fa.jpg</url>
      <title>DEV Community: Alex Xiao</title>
      <link>https://dev.to/axyj</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/axyj"/>
    <language>en</language>
    <item>
      <title>The progress bar that made typing lag: debugging a 331 ms INP in a React game</title>
      <dc:creator>Alex Xiao</dc:creator>
      <pubDate>Fri, 13 Nov 2026 16:00:42 +0000</pubDate>
      <link>https://dev.to/axyj/the-progress-bar-that-made-typing-lag-debugging-a-331-ms-inp-in-a-react-game-1940</link>
      <guid>https://dev.to/axyj/the-progress-bar-that-made-typing-lag-debugging-a-331-ms-inp-in-a-react-game-1940</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A friend told me that typing felt laggy in &lt;a href="https://museek.xiao-web.com/" rel="noopener noreferrer"&gt;Museek&lt;/a&gt;, 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 &lt;code&gt;width&lt;/code&gt; once per second, plus a full-screen &lt;code&gt;backdrop-filter: blur()&lt;/code&gt;. Switching the bar to &lt;code&gt;transform&lt;/code&gt; cut background Rendering time by roughly 7x and took keystrokes out of the slow interactions list.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug report
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://museek.xiao-web.com/" rel="noopener noreferrer"&gt;Museek&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;A friend playing a game sent me this: &lt;em&gt;"There's a delay between what I type and what shows up in the answer field."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In a game where you're racing a 30-second timer, that's not a cosmetic issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first guess (and why it was wrong)
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;filteredArtists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database_artists&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;artistQuery&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;artistQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artistQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// once per item!&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;internationalArtist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;artistSearch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artistQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;filteredArtists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="n"&gt;artistQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database_artists&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ...uses artistSearch... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Step 1: measure properly (and beware of your extensions)
&lt;/h2&gt;

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

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxosewhpuu9mgpx4wl8ys.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxosewhpuu9mgpx4wl8ys.png" alt="Chrome DevTools trace: React DevTools extension causing 191 ms of forced reflow" width="800" height="641"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;installHook.js&lt;/code&gt; is injected by the &lt;strong&gt;React Developer Tools extension&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson #1: profile in an incognito window&lt;/strong&gt;, where extensions are disabled by default. Your users don't run React DevTools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the real numbers
&lt;/h2&gt;

&lt;p&gt;Same test, incognito window, 4x CPU, on the live site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;INP: 331 ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;INP breakdown: input delay 3 ms, &lt;strong&gt;processing 297 ms&lt;/strong&gt;, presentation delay 31 ms&lt;/li&gt;
&lt;li&gt;Inside that processing time: a single purple &lt;strong&gt;Layout of ~290 ms&lt;/strong&gt;, triggered by &lt;code&gt;Event: textInput&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scripting for the whole interaction: about 30 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdz1yzr84fxcnyc5hr84k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdz1yzr84fxcnyc5hr84k.png" alt="Performance panel: INP 331 ms with continuous width animations on the timer bar" width="800" height="847"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So JavaScript wasn't the bottleneck. &lt;strong&gt;Layout&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Then the Animations track: a continuous chain of &lt;code&gt;width&lt;/code&gt; segments, one per second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: find the pattern
&lt;/h2&gt;

&lt;p&gt;Zooming in on the slow keystroke, the timing was suspicious. It landed &lt;strong&gt;exactly at the start of a new&lt;/strong&gt; &lt;code&gt;width&lt;/code&gt; &lt;strong&gt;segment&lt;/strong&gt;, right after a tick of the game clock.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz53vt3ho6tr2xfrhriz3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz53vt3ho6tr2xfrhriz3.png" alt="Slow keystroke lining up with the start of a width animation on the timer bar" width="787" height="643"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The animated element was my timer bar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;
  &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"h-full rounded-full transition-all duration-1000 ease-linear ..."&lt;/span&gt;
  &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`${((totalPhaseTime - timeLeft) / totalPhaseTime) * 100}%`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every second, &lt;code&gt;timeLeft&lt;/code&gt; changes. That one state update does three things at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;it updates the "Time left" text under the bar,&lt;/li&gt;
&lt;li&gt;it updates the same text in the header of the open answer field,&lt;/li&gt;
&lt;li&gt;it starts a new 1-second &lt;code&gt;width&lt;/code&gt; transition on the bar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three invalidate layout. Animating &lt;code&gt;width&lt;/code&gt; is especially bad: the browser has to recompute layout &lt;strong&gt;on every frame&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: &lt;code&gt;transform&lt;/code&gt; instead of &lt;code&gt;width&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt; 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 &lt;code&gt;translateX&lt;/code&gt;, inside a container that has &lt;code&gt;overflow: hidden&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"h-2 w-full overflow-hidden rounded-full bg-(--white)/20"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;
    &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"h-full w-full rounded-full transition-transform duration-1000 ease-linear will-change-transform ..."&lt;/span&gt;
    &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`translateX(-${(timeLeft / totalPhaseTime) * 100}%)`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why&lt;/strong&gt; &lt;code&gt;translateX&lt;/code&gt; &lt;strong&gt;and not&lt;/strong&gt; &lt;code&gt;scaleX&lt;/code&gt;&lt;strong&gt;?&lt;/strong&gt; &lt;code&gt;scaleX&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;transition-all&lt;/code&gt; &lt;strong&gt;→&lt;/strong&gt; &lt;code&gt;transition-transform&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; &lt;code&gt;transition-all&lt;/code&gt; 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.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;will-change-transform&lt;/code&gt; promotes the bar to its own compositor layer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tabular-nums&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Result
&lt;/h3&gt;

&lt;p&gt;Same test, production build (run locally this time), incognito, 4x CPU. The Animations track now says &lt;code&gt;transform&lt;/code&gt; instead of &lt;code&gt;width&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhb64c5w6bjj5b90qwlvb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhb64c5w6bjj5b90qwlvb.png" alt="After the fix: transform animations and short keystroke interactions" width="730" height="758"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Over the recording&lt;/th&gt;
&lt;th&gt;Before (11.6 s)&lt;/th&gt;
&lt;th&gt;After (7.8 s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rendering&lt;/td&gt;
&lt;td&gt;2,217 ms (19%)&lt;/td&gt;
&lt;td&gt;213 ms (2.7%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Painting&lt;/td&gt;
&lt;td&gt;954 ms (8.2%)&lt;/td&gt;
&lt;td&gt;71 ms (0.9%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;h2&gt;
  
  
  Bonus round: the full-screen blur
&lt;/h2&gt;

&lt;p&gt;That slow click was the one that opens the answer field. Its breakdown was very different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;processing: &lt;strong&gt;13 ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;presentation delay: &lt;strong&gt;353 ms&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4u2sdx1i0cp5jswgxmf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4u2sdx1i0cp5jswgxmf.png" alt="INP breakdown of a click: 13 ms processing, 353 ms presentation delay" width="729" height="771"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My code reacted instantly. The browser took 353 ms to &lt;strong&gt;show&lt;/strong&gt; the result. When the field opens, a full-screen overlay appears behind it with &lt;code&gt;backdrop-blur-sm&lt;/code&gt;. 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.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpn5jswhlussq9vhtgkk0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpn5jswhlussq9vhtgkk0.png" alt="Same click without backdrop blur: 237 ms presentation delay" width="700" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One last tweak: the overlay was mounted conditionally (&lt;code&gt;{isActive &amp;amp;&amp;amp; &amp;lt;div ... /&amp;gt;}&lt;/code&gt;), 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;
  &lt;span class="n"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;
  &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sb"&gt;`fixed inset-0 z-40 bg-(--background)/80 transition-opacity duration-250 ease-out ${
    isActive ? "opacity-100" : "pointer-events-none opacity-0"
  }`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, only the very &lt;strong&gt;first&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm taking away from this
&lt;/h2&gt;

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

&lt;p&gt;The changes are in two small commits in the &lt;a href="https://github.com/AXYJ/museek" rel="noopener noreferrer"&gt;Museek repository&lt;/a&gt;: &lt;a href="https://github.com/AXYJ/museek/commit/dfddfd744b12da54c738ce7ae6d30128b446ffa8" rel="noopener noreferrer"&gt;&lt;code&gt;dfddfd7&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/AXYJ/museek/commit/02a0f4207c14e9dafcceee081755e069e2559b56" rel="noopener noreferrer"&gt;&lt;code&gt;02a0f42&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to check that typing is smooth now, &lt;a href="https://museek.xiao-web.com/" rel="noopener noreferrer"&gt;start a game on Museek&lt;/a&gt;: it's free, there's no account, and you can play with friends using your own playlists. You can also read the &lt;a href="https://xiao-web.com/projects/museek" rel="noopener noreferrer"&gt;full case study on my portfolio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>css</category>
    </item>
    <item>
      <title>Building a multiplayer music quiz where everyone brings their own playlist (Next.js + Socket.io)</title>
      <dc:creator>Alex Xiao</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:40:09 +0000</pubDate>
      <link>https://dev.to/axyj/building-a-multiplayer-music-quiz-where-everyone-brings-their-own-playlist-nextjs-socketio-304n</link>
      <guid>https://dev.to/axyj/building-a-multiplayer-music-quiz-where-everyone-brings-their-own-playlist-nextjs-socketio-304n</guid>
      <description>&lt;p&gt;I'm Alex, a web developer in Brussels. This is the story of &lt;a href="https://museek.xiao-web.com" rel="noopener noreferrer"&gt;Museek&lt;/a&gt;, a free multiplayer music quiz I built on my own: everyone brings a public Spotify, Deezer or Apple Music playlist, Museek shuffles all the tracks together, and each round you guess the artist or the title. Up to 12 players, no account, desktop, tablet or mobile.&lt;/p&gt;

&lt;p&gt;Two things up front, because they shape the design: &lt;strong&gt;it is not a race&lt;/strong&gt; (speed doesn't count, everyone can score on every track), and there is &lt;strong&gt;no built-in chat&lt;/strong&gt; , so the easiest way to play is in a voice call with friends.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://museek.xiao-web.com/" rel="noopener noreferrer"&gt;https://museek.xiao-web.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/AXYJ/museek" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Here are the parts that were the most interesting to build.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwehp89rsj8hgm3zot0zb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwehp89rsj8hgm3zot0zb.png" alt="Museek lobby" width="775" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Next.js (React, TypeScript) on the front, Node.js + Express + &lt;a href="http://Socket.io" rel="noopener noreferrer"&gt;Socket.io&lt;/a&gt; on the back, in one monorepo. There is no database: rooms live in memory on the server and disappear when the last player leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Importing playlists from three platforms
&lt;/h2&gt;

&lt;p&gt;The idea only works if a player can paste a link from the service they already use. Each platform needed a different approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deezer&lt;/strong&gt; : the public API (&lt;code&gt;api.deezer.com/playlist/{id}&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spotify&lt;/strong&gt; : playlists are read from the public embed page, which exposes the track list in its page data. I used the official API only for albums.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple Music&lt;/strong&gt; : the playlist page is scraped with Cheerio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An honest caveat: two of the three rely on page structures I don't control, so they can break when the platforms change them. That's the main maintenance risk of the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Never trust a pasted link
&lt;/h3&gt;

&lt;p&gt;Players paste arbitrary links, and the server fetches them. Left unchecked, that is a server-side request forgery (SSRF) hole: someone could make the server call an internal address. So links go through a strict allow-list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ALLOWED_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;deezer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;\&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;deezer&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;$&lt;span class="o"&gt;|^&lt;/span&gt;&lt;span class="n"&gt;deezer&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;$&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;spotify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;\&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;spotify&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;$&lt;span class="o"&gt;|^&lt;/span&gt;&lt;span class="n"&gt;spotify&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;$&lt;span class="o"&gt;|^&lt;/span&gt;&lt;span class="n"&gt;spotify&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;$&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;apple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="n"&gt;music&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;itunes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apple&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;$&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check runs on the hostname parsed by &lt;code&gt;new URL&lt;/code&gt;, never on the raw text, so &lt;code&gt;https://evil.com/?x=deezer.com&lt;/code&gt; and &lt;code&gt;https://deezer.com@evil.com&lt;/code&gt; are refused. Only &lt;code&gt;https&lt;/code&gt; without a custom port is accepted. Short links (&lt;code&gt;spotify.link&lt;/code&gt;, &lt;code&gt;deezer.page.link&lt;/code&gt;) are followed redirect by redirect, and every hop must stay on an allowed domain. There is a test file dedicated to this.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Typo-tolerant answers
&lt;/h2&gt;

&lt;p&gt;Players type the artist and/or the title. The comparison is forgiving: case and accents are ignored, and everything that isn't a letter or a digit is stripped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;str&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"NFD"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;̀&lt;span class="o"&gt;-&lt;/span&gt;ͯ&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;\&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;L&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;\&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;M&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;\&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;N&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Artists are split on &lt;code&gt;,&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;feat.&lt;/code&gt;, &lt;code&gt;with&lt;/code&gt;… and each one is checked. Scoring is simple: 1 point for the artist (0.5 if there are several and you only find some of them) plus 1 point for the title. An autocomplete suggests names from the loaded playlists, which removes most typing errors.&lt;/p&gt;

&lt;p&gt;The normalisation of each track is computed once when the game starts, not on every answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc79t23nd296z57itk4ka.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc79t23nd296z57itk4ka.webp" alt="Museek autocompletion system" width="778" height="632"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Non-Latin names
&lt;/h2&gt;

&lt;p&gt;Many playlists mix scripts: Japanese, Chinese, Korean, Cyrillic. Players on a Latin keyboard can't type those, so each track also gets an "international" (romanised) name, and both versions are accepted. The pipeline is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Latin text stays as is.&lt;/li&gt;
&lt;li&gt;Anything with kana goes through Kuroshiro (Japanese reading, kanji included).&lt;/li&gt;
&lt;li&gt;Other scripts (Chinese, Korean, kanji alone…) go to an LLM through the Groq API, and fall back to local transliteration if the call fails or no key is set.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only titles and artist names are sent to the API, never usernames or IP addresses. It's documented in the app's privacy page.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A server-driven game clock
&lt;/h2&gt;

&lt;p&gt;Every player must see the same countdown, even with a laggy phone or a throttled background tab. Instead of counting down on each client, the server only sends two timestamps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getGameTiming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gameStartTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GameTiming&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;gameStartTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serverNow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client estimates the gap between the two clocks once, then derives the round, the phase and the time left from the current time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;toGameClock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gameStartTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverNow&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A round is &lt;code&gt;time&lt;/code&gt; seconds of guessing, 5 seconds of reveal and 2 seconds of transition. Because the state is a pure function of the clock, a player who reloads the page or comes back from another app lands on the right screen without any "catch-up" message.&lt;/p&gt;

&lt;p&gt;The limit: network latency is ignored, so clocks can differ by a few dozen milliseconds. Since speed doesn't affect the score, that's fine, which is one more reason the game isn't a race.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fajb2byt1a1mhc1eddr3q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fajb2byt1a1mhc1eddr3q.webp" alt="Museek showing answer" width="744" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Disconnections and reconnection
&lt;/h2&gt;

&lt;p&gt;On mobile, players switch apps all the time. Each player has a private ID stored in the browser; on reconnect the server swaps the socket and sends back the current state. A 5-minute grace period before a disconnected player is removed, and the host role is transferred if the host doesn't come back. One subtle bug class: a stale socket firing its &lt;code&gt;disconnect&lt;/code&gt; event after the player has already reconnected on a new one. The handler ignores it when &lt;code&gt;player.socketId !== socket.id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Privacy by default
&lt;/h2&gt;

&lt;p&gt;No database, no analytics, no ads. Room data is purged when the room empties. Other players never receive your reconnection ID or your playlist link, only a random public ID. The server also rate-limits per IP (room creation, playlist loads, bad room codes, simultaneous connections).&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Testing with real clients
&lt;/h2&gt;

&lt;p&gt;The backend tests start their own server and play games with real &lt;a href="http://Socket.IO" rel="noopener noreferrer"&gt;Socket.IO&lt;/a&gt; clients: answers and scores, host rights, identity, disconnections and reconnections, reset, mixed-script playlists, the link allow-list, and rate limits. Testing at the socket level caught more real problems than unit tests alone would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://museek.xiao-web.com" rel="noopener noreferrer"&gt;museek.xiao-web.com&lt;/a&gt;: free, no signup. It's a multiplayer game, so bring a few friends and a voice call. Feedback is very welcome, especially on onboarding and on playlist links that don't load.&lt;/p&gt;

&lt;p&gt;Museek is an independent project, not affiliated with Spotify, Deezer or Apple.&lt;/p&gt;

&lt;p&gt;I used an AI assistant while building the project and to help draft this post&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
