<?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: Parsa Jiravand</title>
    <description>The latest articles on DEV Community by Parsa Jiravand (@parsajiravand).</description>
    <link>https://dev.to/parsajiravand</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%2F3831018%2Ff09b70fc-3b0d-4ce2-bb7e-d78ee6f7d701.jpg</url>
      <title>DEV Community: Parsa Jiravand</title>
      <link>https://dev.to/parsajiravand</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/parsajiravand"/>
    <language>en</language>
    <item>
      <title>React 19.2 useEffectEvent: Why It Won't Fix Your Deps Array</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:20:56 +0000</pubDate>
      <link>https://dev.to/parsajiravand/react-192-useeffectevent-why-it-wont-fix-your-deps-array-4743</link>
      <guid>https://dev.to/parsajiravand/react-192-useeffectevent-why-it-wont-fix-your-deps-array-4743</guid>
      <description>&lt;p&gt;Your chat room component reconnects every time someone flips the dark-mode switch. Messages in flight get dropped, the "typing…" indicator resets, and support tickets call it "random." The bug isn't the dark-mode toggle — it's that &lt;code&gt;theme&lt;/code&gt; is sitting in a &lt;code&gt;useEffect&lt;/code&gt; dependency array next to &lt;code&gt;roomId&lt;/code&gt;, and React has no way to know that only one of those two values should actually restart the connection.&lt;/p&gt;

&lt;p&gt;The reflex fix — deleting &lt;code&gt;theme&lt;/code&gt; from the array and silencing the lint warning — trades one bug for a worse one: the notification that fires on connect now shows whatever theme was active the moment the connection opened, forever, no matter how many times the user flips the switch afterward. That's a stale closure, and it's the single most common &lt;code&gt;useEffect&lt;/code&gt; bug there is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React 19.2 (released October 1, 2025) shipped a real fix: the &lt;code&gt;useEffectEvent&lt;/code&gt; Hook.&lt;/strong&gt; It lets an Effect read a value's &lt;em&gt;current&lt;/em&gt; state without that value forcing the Effect to re-run — something no combination of dependency-array tricks could do safely before. This article covers what &lt;code&gt;useEffectEvent&lt;/code&gt; actually does, the exact rules for using it, and the one way people keep misusing it that makes things worse, not better.&lt;/p&gt;

&lt;p&gt;This article is written against &lt;strong&gt;React 19.2+ / 19.3.0&lt;/strong&gt; (verified against the current npm &lt;code&gt;latest&lt;/code&gt; dist-tag for &lt;code&gt;react&lt;/code&gt; and cross-checked coverage of the official 19.2 release notes and the React docs' "Separating Events from Effects" guide; &lt;code&gt;react.dev&lt;/code&gt; itself was unreachable from this run's network, so claims here lean on the npm registry plus multiple independent write-ups of the same release). &lt;code&gt;useEffectEvent&lt;/code&gt; requires React 19.2 or later and assumes React 19-era function components and hooks throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll learn
&lt;/h2&gt;

&lt;p&gt;By the end of this article you'll be able to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explain the real difference between a value an Effect must &lt;em&gt;depend on&lt;/em&gt; and a value an Effect only needs to &lt;em&gt;read the latest copy of&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Reproduce the stale-closure bug that "just remove it from the deps array" causes, and name why it happens&lt;/li&gt;
&lt;li&gt;Extract non-reactive logic out of an Effect with &lt;code&gt;useEffectEvent&lt;/code&gt; and call it correctly&lt;/li&gt;
&lt;li&gt;List the hard rules — where it can be called, what it can't be passed to, why it's excluded from the dependency array — and what breaks if you ignore them&lt;/li&gt;
&lt;li&gt;Recognize when &lt;code&gt;useEffectEvent&lt;/code&gt; is the wrong tool, because the value genuinely should resynchronize the Effect&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;You've written a &lt;code&gt;useEffect&lt;/code&gt; with a dependency array, you've seen the &lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; lint warning, and you've been tempted to either silence it or just add everything it asks for. No class components or Server Components knowledge required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The problem: one dependency array, two different jobs&lt;/li&gt;
&lt;li&gt;The mental model: reactive values vs. Effect Events&lt;/li&gt;
&lt;li&gt;Stage 1 — the naive fix and its stale closure&lt;/li&gt;
&lt;li&gt;Stage 2 — useEffectEvent, correctly&lt;/li&gt;
&lt;li&gt;Stage 3 — more than one Effect Event&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: one dependency array, two different jobs
&lt;/h2&gt;

&lt;p&gt;Here's the chat room, written the obvious way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ChatRoom&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connected!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome to &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; is doing its job here: the Effect reads both &lt;code&gt;roomId&lt;/code&gt; and &lt;code&gt;theme&lt;/code&gt;, so both are listed. The code is "correct" by the lint rule's own standard. It's also wrong in a way the lint rule has no way to catch, because the rule only knows one move — &lt;em&gt;list everything you read&lt;/em&gt; — and that move doesn't distinguish between two genuinely different jobs a value can have inside an Effect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;roomId&lt;/code&gt; decides what the Effect &lt;em&gt;does&lt;/em&gt;.&lt;/strong&gt; A different room means a different connection. If &lt;code&gt;roomId&lt;/code&gt; changes and the Effect doesn't re-run, you're connected to the wrong room. This is a &lt;strong&gt;reactive value&lt;/strong&gt; — the Effect's whole reason for existing is synchronized to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;theme&lt;/code&gt; is only &lt;em&gt;read&lt;/em&gt; once the connection is already open&lt;/strong&gt;, to decide what the notification looks like. It has no bearing on which room you connect to. Listing it as a dependency doesn't make the code more correct — it makes the Effect re-run (disconnect, then reconnect) for a reason that has nothing to do with what the Effect is for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Toggle dark mode in this version and the user drops their connection. Not because anything about the connection needed to change — because the lint rule, correctly, cannot tell the difference between "this value controls what the Effect synchronizes with" and "this value is read once a thing the Effect already started happens to fire."&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: reactive values vs. Effect Events
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; a &lt;code&gt;useEffect&lt;/code&gt; dependency array isn't a list of "everything this function touches" — it's a list of &lt;strong&gt;reactive values the Effect's job depends on&lt;/strong&gt;. If a value changing should legitimately restart what the Effect is doing, it belongs in the array, full stop; that's not a style choice, it's correctness. But some reads inside an Effect aren't about &lt;em&gt;what the Effect is doing&lt;/em&gt; at all — they're about &lt;em&gt;what to do when something the Effect already set up happens later&lt;/em&gt;. React 19.2 gives that second category a name — an &lt;strong&gt;Effect Event&lt;/strong&gt; — and a Hook to create one: &lt;code&gt;useEffectEvent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An Effect Event is a function, declared with &lt;code&gt;useEffectEvent(fn)&lt;/code&gt;, whose body is deliberately &lt;strong&gt;non-reactive&lt;/strong&gt;: every time your component re-renders, React relinks the function to that render's latest props and state, but the function itself is never a dependency and never forces anything to re-run. You call it &lt;em&gt;from inside&lt;/em&gt; an Effect (or from inside another Effect Event) when the thing it does should happen using &lt;strong&gt;whatever is current right now&lt;/strong&gt;, not whatever was current when the Effect last ran.&lt;/p&gt;

&lt;p&gt;Put plainly: &lt;code&gt;roomId&lt;/code&gt; answers "what should this Effect be connected to?" — that's reactive, and it belongs in the array. &lt;code&gt;theme&lt;/code&gt; answers "what should happen, using today's value, when the thing I already connected to does something?" — that's an Effect Event, and it has no business in the array at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1 — the naive fix and its stale closure
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;useEffectEvent&lt;/code&gt; existed, the common workaround was to simply drop &lt;code&gt;theme&lt;/code&gt; from the array and disable the lint rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ChatRoom&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// theme is captured from THIS render, forever, until roomId changes again&lt;/span&gt;
      &lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connected!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally wrong, see below&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;roomId&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome to &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stops the reconnect. It also freezes &lt;code&gt;theme&lt;/code&gt; at whatever it was the moment the Effect last ran. JavaScript closures capture variables by reference to the scope they were created in, not by re-reading them later — so the &lt;code&gt;theme&lt;/code&gt; inside &lt;code&gt;connection.on("connected", …)&lt;/code&gt; is the one value from the render that created this particular closure. Flip dark mode ten times after that; the notification keeps showing the old theme until &lt;code&gt;roomId&lt;/code&gt; changes and the whole Effect reruns. That's the stale closure: the code looks like it reads &lt;code&gt;theme&lt;/code&gt;, but it's reading a frozen copy from the past.&lt;/p&gt;

&lt;p&gt;This is the trap of "just remove it from the deps array and silence the warning": you've fixed the symptom (unwanted reconnects) by reintroducing the exact class of bug the lint rule exists to prevent (stale reads), just somewhere the rule can no longer see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2 — useEffectEvent, correctly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useEffectEvent&lt;/code&gt; splits the two jobs apart explicitly instead of hiding one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffectEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ChatRoom&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&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="nx"&gt;onConnected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useEffectEvent&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="c1"&gt;// Always reads the CURRENT theme, no matter when this runs.&lt;/span&gt;
    &lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connected!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onConnected&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// only roomId — and this time that's actually correct, not a lie&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome to &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;onConnected&lt;/code&gt; is recreated on every render like any other local function, but calling it never reads a frozen snapshot — React always runs it against the component's latest props and state. Its identity is deliberately &lt;em&gt;not&lt;/em&gt; stable across renders (unlike a &lt;code&gt;useState&lt;/code&gt; setter or a &lt;code&gt;useCallback&lt;/code&gt;-memoized function) — that's by design, so nothing can accidentally rely on it staying the same or pass it around like a normal callback. It simply can never legally appear in the dependency array in the first place, so the instability never causes a re-run. The Effect's dependency array now says something true: &lt;em&gt;this Effect only needs to re-run when &lt;code&gt;roomId&lt;/code&gt; changes.&lt;/em&gt; &lt;code&gt;theme&lt;/code&gt; can change a thousand times without touching the connection, and the notification will still report whatever theme is active the instant it fires.&lt;/p&gt;

&lt;p&gt;Toggle dark mode now: no disconnect, no reconnect, and the next notification shows the theme you just switched to. Both halves of the original bug — unwanted reconnects &lt;em&gt;and&lt;/em&gt; stale reads — are gone, because they were never actually the same problem. They were two different values wearing one dependency array.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/react-weekly-useeffectevent-stale-closures/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3 — more than one Effect Event
&lt;/h2&gt;

&lt;p&gt;The pattern scales past a single notification. Say the chat room also has a mute toggle that should silence a "ping" sound without, again, reconnecting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ChatRoom&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMuted&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="nx"&gt;onConnected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useEffectEvent&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="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connected!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&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="nx"&gt;onMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useEffectEvent&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;message&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="nx"&gt;isMuted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;playSound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;logMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onConnected&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// roomId is the only reactive value here&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome to &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;theme&lt;/code&gt; and &lt;code&gt;isMuted&lt;/code&gt; can each change independently, as often as the user likes, and neither one is a dependency anywhere. The rule that decides what goes in &lt;code&gt;useEffect&lt;/code&gt;'s array hasn't changed — list every reactive value the Effect's &lt;em&gt;behavior&lt;/em&gt; depends on — it's just that &lt;code&gt;theme&lt;/code&gt; and &lt;code&gt;isMuted&lt;/code&gt; were never one of those to begin with. They were Effect Event inputs all along; &lt;code&gt;useEffectEvent&lt;/code&gt; just gives that distinction a real API instead of a lint-rule workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can only call an Effect Event from inside an Effect, or from inside another Effect Event.&lt;/strong&gt; Calling it during render, or handing it to a child component or another Hook as a prop, is not supported — &lt;code&gt;eslint-plugin-react-hooks&lt;/code&gt; (6.1 and later; the current major is 7.x) flags both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It must be declared at the top level of a component or custom Hook&lt;/strong&gt;, like any other Hook — not inside a loop, condition, or nested function. If you need one per item in a list, pull that item into its own component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot go in a dependency array.&lt;/strong&gt; You don't need to add it — the lint rule never asks you to — and if you add it anyway, a current &lt;code&gt;eslint-plugin-react-hooks&lt;/code&gt; flags it directly (&lt;code&gt;Functions returned from useEffectEvent must not be included in the dependency array&lt;/code&gt;) and tells you to remove it, because the whole point is that it isn't reactive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a blanket fix for &lt;code&gt;exhaustive-deps&lt;/code&gt; warnings.&lt;/strong&gt; If a value genuinely should restart the Effect — &lt;code&gt;roomId&lt;/code&gt; in every example above — wrapping it in &lt;code&gt;useEffectEvent&lt;/code&gt; to make the warning go away reintroduces the exact stale-closure bug Stage 1 showed, just with a newer API wrapped around it. The question to ask every time is "should this value actually restart what the Effect is doing?", not "how do I make the warning stop."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't replace &lt;code&gt;useRef&lt;/code&gt; for everything.&lt;/strong&gt; If you need to read a value from &lt;em&gt;outside&lt;/em&gt; any Effect at all — say, inside a &lt;code&gt;setTimeout&lt;/code&gt; callback registered by an event handler, not an Effect — &lt;code&gt;useEffectEvent&lt;/code&gt;'s restriction to Effects means a ref, or a plain callback prop, is still the right tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Components never run Effects&lt;/strong&gt;, so this is purely a client-component concern; it doesn't change anything about the server/client boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reach for &lt;code&gt;useEffectEvent&lt;/code&gt;&lt;/strong&gt; when an Effect's trigger condition should stay narrow (one connection per &lt;code&gt;roomId&lt;/code&gt;) but an action it fires needs to use whatever is current at the moment it fires, not whatever was current when the Effect last started.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't reach for it reflexively&lt;/strong&gt; every time &lt;code&gt;exhaustive-deps&lt;/code&gt; complains. First ask whether the flagged value should actually resynchronize the Effect. If yes, keep it in the array — that's not friction, that's the rule doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Effect Events small and named for what they do&lt;/strong&gt; (&lt;code&gt;onConnected&lt;/code&gt;, &lt;code&gt;onMessage&lt;/code&gt;), not for what they avoid (&lt;code&gt;stableTheme&lt;/code&gt;). The name should describe the moment it fires, because that's the mental model you're encoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pair it with a real connection/subscription pattern&lt;/strong&gt;, not a one-off side effect. The value of splitting reactive setup from non-reactive reaction shows up most in Effects that stay alive across renders — connections, subscriptions, timers — not in an Effect that runs once and is done.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;useEffectEvent&lt;/code&gt; a replacement for &lt;code&gt;useCallback&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;useCallback&lt;/code&gt; memoizes a function's &lt;em&gt;identity&lt;/em&gt; across renders so a child doesn't re-render or a dependency array doesn't see a "new" function every time. &lt;code&gt;useEffectEvent&lt;/code&gt; doesn't memoize for performance — it changes what the function is &lt;em&gt;allowed to see&lt;/em&gt; (always-latest, never-reactive) and &lt;em&gt;where it can be called from&lt;/em&gt; (only Effects and other Effect Events). They solve different problems and aren't interchangeable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I call an Effect Event from a click handler?
&lt;/h3&gt;

&lt;p&gt;No — an Effect Event can only be called from inside an Effect or another Effect Event, and the lint rule enforces this. If you need current-value behavior inside an &lt;code&gt;onClick&lt;/code&gt;, you already have it: a normal event handler always closes over the latest render's values naturally, because it's recreated every render. The stale-closure problem this Hook solves is specific to long-lived Effects, not short-lived event handlers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Did this exist before React 19.2?
&lt;/h3&gt;

&lt;p&gt;Yes, behind an unstable name. It shipped for a long stretch as &lt;code&gt;experimental_useEffectEvent&lt;/code&gt;, and the stabilized, unprefixed &lt;code&gt;useEffectEvent&lt;/code&gt; landed in React 19.2 (October 2025). Code written against the experimental name needs the import updated, but the behavior and rules carried over.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;useEffectEvent&lt;/code&gt; need a special ESLint setup?
&lt;/h3&gt;

&lt;p&gt;You need a current &lt;code&gt;eslint-plugin-react-hooks&lt;/code&gt; (6.1+; 7.x as of this writing) for the linter to correctly exclude Effect Event functions from dependency-array warnings instead of flagging them as missing. Without it, you'll fight the linter over something it already knows how to handle in a current version.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if I put the Effect Event function in the dependency array anyway?
&lt;/h3&gt;

&lt;p&gt;A current &lt;code&gt;eslint-plugin-react-hooks&lt;/code&gt; flags it and tells you to remove it — it won't silently let it through. Functionally, adding it wouldn't do anything useful even if the linter allowed it: the function reference itself isn't what makes the behavior reactive or not. The reactivity question was never about the Effect Event's identity; it's about the values &lt;em&gt;inside&lt;/em&gt; it, which are intentionally not tracked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A value decides &lt;em&gt;what&lt;/em&gt; the Effect connects to / sets up&lt;/td&gt;
&lt;td&gt;Keep it in the dependency array&lt;/td&gt;
&lt;td&gt;It's reactive — the Effect's job changes when it changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A value is only read &lt;em&gt;after&lt;/em&gt; something the Effect set up fires&lt;/td&gt;
&lt;td&gt;Read it inside a &lt;code&gt;useEffectEvent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Always current, never forces a re-run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tempted to silence &lt;code&gt;exhaustive-deps&lt;/code&gt; by deleting a dependency&lt;/td&gt;
&lt;td&gt;Ask: should this value restart the Effect? If yes, keep it. If no, wrap the code that reads it in &lt;code&gt;useEffectEvent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Deleting it silently reintroduces a stale closure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need current-value behavior inside an &lt;code&gt;onClick&lt;/code&gt;/&lt;code&gt;onChange&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Do nothing special&lt;/td&gt;
&lt;td&gt;Event handlers already close over the latest render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need current-value behavior inside a &lt;code&gt;setTimeout&lt;/code&gt; set up outside any Effect&lt;/td&gt;
&lt;td&gt;Use a ref&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;useEffectEvent&lt;/code&gt; only works from inside Effects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffectEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ChatRoom&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMuted&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Non-reactive reads live here — never in the deps array, never the trigger.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onConnected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useEffectEvent&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="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connected!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&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="c1"&gt;// Only truly reactive values live here.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onConnected&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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="nx"&gt;roomId&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Welcome to &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;useEffect&lt;/code&gt; dependency array lists &lt;strong&gt;reactive values the Effect's job depends on&lt;/strong&gt; — not every value the Effect's body happens to read.&lt;/li&gt;
&lt;li&gt;Deleting a value from the array to stop an unwanted re-run reintroduces a &lt;strong&gt;stale closure&lt;/strong&gt;: the deleted value gets frozen at whatever it was when the Effect last ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useEffectEvent&lt;/code&gt;&lt;/strong&gt; (stable since React 19.2, October 2025) gives non-reactive reads a real home: the function always sees the latest props/state, but it's never a dependency and never retriggers the Effect.&lt;/li&gt;
&lt;li&gt;It can only be called from inside an Effect or another Effect Event — never during render, never passed to a child or another Hook.&lt;/li&gt;
&lt;li&gt;It is not a workaround for &lt;code&gt;exhaustive-deps&lt;/code&gt; — if a value should genuinely resynchronize the Effect, it still belongs in the array.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/react-weekly-useeffectevent-stale-closures/quiz" rel="noopener noreferrer"&gt;Take the 8-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That chat room from the opening scene now reconnects exactly when it should — when the room changes — and never when someone just prefers a darker screen. The bug was never really about dark mode; it was two different questions hiding inside one dependency array, and &lt;code&gt;useEffectEvent&lt;/code&gt; is what finally lets you answer them separately.&lt;/p&gt;

&lt;p&gt;If &lt;a href="https://dev.to/parsajiravand/react-derived-state-why-that-usestate-is-probably-a-bug-37h0"&gt;&lt;code&gt;React Derived State&lt;/code&gt;&lt;/a&gt; taught you that an Effect syncing one piece of state into another is usually a bug, this is the companion lesson for the Effects you &lt;em&gt;do&lt;/em&gt; need: not every value they read is there for the same reason, and now there's a real API for telling them apart. And if &lt;a href="https://dev.to/parsajiravand/react-re-render-vs-remount-what-actually-triggers-each-5fok"&gt;&lt;code&gt;Re-render vs Remount&lt;/code&gt;&lt;/a&gt; taught you that &lt;code&gt;key&lt;/code&gt; decides which component instance you're looking at, the same instinct applies here — &lt;code&gt;roomId&lt;/code&gt; decides which &lt;em&gt;connection&lt;/em&gt; you're looking at, and &lt;code&gt;theme&lt;/code&gt; never should have.&lt;/p&gt;

&lt;p&gt;Have you shipped a stale-closure bug from an Effect's dependency array — did you find it from a bug report, or from reading the code? Tell me below.&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/react-derived-state-why-that-usestate-is-probably-a-bug-37h0"&gt;React Derived State: Why That useState Is Probably a Bug&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/react-193-viewtransition-animate-state-without-losing-it-4kpp"&gt;React 19.3 ViewTransition: Animate State Without Losing It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/react-re-render-vs-remount-what-actually-triggers-each-5fok"&gt;React Re-render vs Remount: What Actually Triggers Each&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Inferred Type Predicates Won't Save filter(Boolean). Here's What Will.</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:17:17 +0000</pubDate>
      <link>https://dev.to/parsajiravand/inferred-type-predicates-wont-save-filterboolean-heres-what-will-2dok</link>
      <guid>https://dev.to/parsajiravand/inferred-type-predicates-wont-save-filterboolean-heres-what-will-2dok</guid>
      <description>&lt;p&gt;Open any "what's new in TypeScript 5.5" roundup from the last two years and you'll find some version of the same line: inferred type predicates finally fixed &lt;code&gt;.filter(Boolean)&lt;/code&gt;. It gets repeated because it's satisfying — a years-old annoyance, solved for free, no code changes needed. It also isn't true.&lt;/p&gt;

&lt;p&gt;The feature that's actually in 5.5 is narrower than the myth, and more useful once you see what it's really for. You've probably been deleting type guards you didn't need to delete, right next to a &lt;code&gt;.filter(Boolean)&lt;/code&gt; that's just as untyped as it was in 2019.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim, and why it's believable
&lt;/h2&gt;

&lt;p&gt;Here's the example every one of those roundups leads with. Before TypeScript 5.5, this didn't narrow:&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;const&lt;/span&gt; &lt;span class="nx"&gt;maybeNumbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// previously: (number | null)[] — TypeScript didn't trust the callback&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maybeNumbers&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="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You wrote a callback that obviously removes &lt;code&gt;null&lt;/code&gt;, ran it, and TypeScript still handed you back &lt;code&gt;(number | null)[]&lt;/code&gt;. The only way to get a real &lt;code&gt;number[]&lt;/code&gt; was to spell it out by hand:&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;const&lt;/span&gt; &lt;span class="nx"&gt;numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maybeNumbers&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="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;(n): n is number =&amp;gt;&lt;/code&gt; syntax is a type predicate — a little promise to the compiler that says "if this returns &lt;code&gt;true&lt;/code&gt;, treat the input as this narrower type." Writing it by hand, every time, for a check this obvious, was the exact kind of busywork TypeScript is supposed to save you from. &lt;a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html" rel="noopener noreferrer"&gt;TypeScript 5.5 fixed it&lt;/a&gt;: the plain &lt;code&gt;(n) =&amp;gt; n !== null&lt;/code&gt; now gets the predicate inferred automatically. No annotation. Same code, better type.&lt;/p&gt;

&lt;p&gt;So far, every word of the popular claim checks out. Here's where it quietly goes off the rails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the one line everyone actually writes
&lt;/h2&gt;

&lt;p&gt;Nobody filters nulls with &lt;code&gt;n !== null&lt;/code&gt; in real code nearly as often as they reach for the shorthand:&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;const&lt;/span&gt; &lt;span class="nx"&gt;configs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// still (Config | null | undefined)[] in every TypeScript version as of this writing&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;configs&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="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste that into the TypeScript playground. The result is still &lt;code&gt;(Config | null | undefined)[]&lt;/code&gt; — the exact array you started with, falsy values and all. The feature that rewrote &lt;code&gt;n !== null&lt;/code&gt; into a type guard does nothing here, on the single most common one-liner for exactly this job.&lt;/p&gt;

&lt;p&gt;Quick gut check before you scroll: what's different between &lt;code&gt;(n) =&amp;gt; n !== null&lt;/code&gt; and &lt;code&gt;Boolean&lt;/code&gt;, if they do the same job?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the compiler can see one and not the other
&lt;/h2&gt;

&lt;p&gt;Inferred type predicates work by TypeScript reading a function's &lt;em&gt;body&lt;/em&gt; and recognizing a pattern: one return statement, no implicit fall-through, the parameter isn't reassigned, and the thing being returned is a boolean check tied directly to that parameter. &lt;code&gt;(n) =&amp;gt; n !== null&lt;/code&gt; qualifies on every count — it's a tiny, transparent function, and the compiler can see exactly what it decides and why.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Boolean&lt;/code&gt; isn't a function you wrote. It's the global constructor, declared in TypeScript's own standard library as a call signature with no visible implementation — &lt;code&gt;(value?: any) =&amp;gt; boolean&lt;/code&gt;, full stop. There's no body for the inference engine to read, because as far as the type checker is concerned, there's no body at all. It's an ambient declaration, same category as &lt;code&gt;parseInt&lt;/code&gt; or &lt;code&gt;Array.isArray&lt;/code&gt;: TypeScript trusts the signature you gave it and nothing more. Teaching the compiler to special-case &lt;code&gt;.filter(Boolean)&lt;/code&gt; specifically would mean hard-coding one function name into the type checker — not inference, a workaround with a name attached.&lt;/p&gt;

&lt;p&gt;That's the actual boundary of the 5.5 feature: it infers predicates from code it can read. &lt;code&gt;Boolean&lt;/code&gt; was never going to be included, in 5.5 or any release after it, unless the standard library itself grows a dedicated overload for it — which, as of this writing, it hasn't.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/inferred-type-predicates-filter-boolean/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the mix-up happened
&lt;/h2&gt;

&lt;p&gt;Nobody misreported this out of carelessness. &lt;code&gt;.filter(n =&amp;gt; n !== null)&lt;/code&gt; and &lt;code&gt;.filter(Boolean)&lt;/code&gt; solve the identical problem and read, to a human, like two spellings of the same idea. The release notes' headline example uses the first form because it's the clean one to explain in a changelog. Readers who've spent years typing the second form saw "filter now narrows" and reasonably assumed their own habit was covered.&lt;/p&gt;

&lt;p&gt;It's the kind of gap that only shows up when you actually run the code, which most roundups — understandably, on a deadline — don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do with your type guards
&lt;/h2&gt;

&lt;p&gt;The real payoff of 5.5 is that you can delete annotations you were writing by hand, as long as the function itself is one TypeScript can read:&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="c1"&gt;// before 5.5 — had to spell out the predicate yourself&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDefined&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;NonNullable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 5.5+ — this now infers the exact same predicate, with no `is` anywhere&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDefined&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&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="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;configs&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="nx"&gt;isDefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Config[] — for real this time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a genuine win: a helper function you've probably had in a &lt;code&gt;utils.ts&lt;/code&gt; file for years just got shorter, and any arrow function shaped like it gets the same treatment for free. What it isn't is a reason to rip out &lt;code&gt;isDefined&lt;/code&gt; and go back to &lt;code&gt;.filter(Boolean)&lt;/code&gt; for brevity — that one line is exactly where the narrowing still doesn't reach. If you want the one-liner and the types, write the predicate yourself:&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;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;configs&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;NonNullable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;Same runtime behavior as &lt;code&gt;.filter(Boolean)&lt;/code&gt;, three extra words, and an array type you can actually trust downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;TypeScript 5.5 didn't make &lt;code&gt;filter(Boolean)&lt;/code&gt; type-safe — it made your own boolean-returning functions smart enough that you stop writing &lt;code&gt;is&lt;/code&gt; annotations for them by hand. Keep the named helper, drop its predicate syntax, and reach for the explicit inline predicate only when you actually want the &lt;code&gt;Boolean&lt;/code&gt;-style one-liner. The compiler was never going to read minds; it just got better at reading your functions.&lt;/p&gt;

&lt;p&gt;Next time a changelog claims a one-line fix for a years-old annoyance, paste the actual snippet into the playground before you repeat it. What's one you've seen repeated that didn't hold up?&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/inferred-type-predicates-filter-boolean/quiz" rel="noopener noreferrer"&gt;Take the 7-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Letting One Widget Reflow the Whole Page. `contain` Scopes It.</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:23:08 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-letting-one-widget-reflow-the-whole-page-contain-scopes-it-1dgg</link>
      <guid>https://dev.to/parsajiravand/stop-letting-one-widget-reflow-the-whole-page-contain-scopes-it-1dgg</guid>
      <description>&lt;p&gt;Open the &lt;strong&gt;Performance&lt;/strong&gt; tab, record five seconds of a dashboard with a live "3 new messages" counter in the corner, and look at the flame chart. There's a good chance you'll see a &lt;strong&gt;Layout&lt;/strong&gt; block fire every time that counter ticks — not sized to one &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, but spanning a chunk of the page that has nothing to do with it.&lt;/p&gt;

&lt;p&gt;Nobody wrote code that does that on purpose. The browser just doesn't know any better: by default, a change anywhere in the DOM is a reason to ask whether anything &lt;em&gt;outside&lt;/em&gt; that element needs to be measured again. A &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; updating its text could, in principle, change its own size, which could push its parent, which could push a sibling, which could ripple all the way out. The layout engine can't assume otherwise, so for anything it isn't sure about, it checks.&lt;/p&gt;

&lt;p&gt;That's the part almost nobody profiles for, because the obvious suspect is always JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone reaches for first: throttle the JS
&lt;/h2&gt;

&lt;p&gt;Say the counter updates from a WebSocket message every couple of seconds, and someone notices the jank. The first instinct is almost always to blame the handler:&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="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="c1"&gt;// "it's probably firing too often" — throttle it&lt;/span&gt;
  &lt;span class="nf"&gt;updateCounterThrottled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;Sometimes that genuinely helps, if the handler is doing real work. But plenty of the time the handler is one &lt;code&gt;textContent&lt;/code&gt; assignment, the throttle buys nothing, and the Layout block in the profiler is exactly the same width as before — because throttling how &lt;em&gt;often&lt;/em&gt; something changes doesn't change how much the browser has to re-check &lt;em&gt;each time&lt;/em&gt; it does. The cost was never in the JS. It was in what layout had to consider every time that one element changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening: layout has no boundaries by default
&lt;/h2&gt;

&lt;p&gt;Every element in the DOM is, by default, part of one connected layout problem. There's no built-in notion of "this subtree is self-contained" — the browser has to be conservative, because CSS gives it plenty of ways for a tiny element to affect something far away: a child wider than its parent, a negative margin, an absolutely positioned descendant escaping its ancestor, intrinsic sizing that depends on content. Any of those &lt;em&gt;could&lt;/em&gt; be happening, so near any DOM change, the engine re-checks more than the one element that changed.&lt;/p&gt;

&lt;p&gt;This is the part the Myth "if it's slow, profile the JS" misses: sometimes the JS is one line, and the real cost is a layout engine doing its job correctly with no boundary to stop at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: draw the boundary yourself
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/contain" rel="noopener noreferrer"&gt;CSS containment&lt;/a&gt; exists to let you make that promise explicit. &lt;code&gt;contain: layout&lt;/code&gt; tells the browser: whatever happens inside this element, it does not affect anything outside it — and nothing outside affects its internal layout either. That's not a performance hint the browser might ignore, the way &lt;code&gt;will-change&lt;/code&gt; is; it's a contract. Once you've made it, the engine can stop checking past the boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* layout + style + paint, no size */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;contain&lt;/code&gt; takes several keywords, and they compose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;layout&lt;/code&gt;&lt;/strong&gt; — the element's internal layout can't leak out, and outside layout can't reach in. This is the one that stops the ripple.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;paint&lt;/code&gt;&lt;/strong&gt; — nothing the element paints (or its descendants paint) can render outside its own box. The browser can skip painting it entirely if it's off-screen or hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;size&lt;/code&gt;&lt;/strong&gt; — the element's size can be computed without looking at its contents at all. Powerful, but it has a real catch (below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;content&lt;/code&gt;&lt;/strong&gt; is shorthand for &lt;code&gt;layout paint style&lt;/code&gt; together. &lt;strong&gt;&lt;code&gt;strict&lt;/code&gt;&lt;/strong&gt; adds &lt;code&gt;size&lt;/code&gt; on top of that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add &lt;code&gt;layout paint&lt;/code&gt; (or &lt;code&gt;content&lt;/code&gt;) to that counter widget's wrapper, and the Layout block in the next profile is scoped to the widget — the rest of the dashboard stops showing up in it, because the browser now has a guarantee it didn't have before.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/css-contain-rendering-isolation/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: &lt;code&gt;size&lt;/code&gt; containment can collapse your element to nothing
&lt;/h2&gt;

&lt;p&gt;Here's where people get burned. &lt;code&gt;contain: strict&lt;/code&gt; sounds like the "turn it all on" option, and &lt;code&gt;size&lt;/code&gt; containment is the part that quietly breaks things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* looks safe — it's not, if .widget has no explicit height */&lt;/span&gt;
&lt;span class="nc"&gt;.widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;strict&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;Size containment means the browser computes this element's size &lt;strong&gt;as if it had no content&lt;/strong&gt; — on purpose, that's the whole point, so it never has to wait on the subtree to know how much space to reserve. But "as if it had no content" means an element with no explicit &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; (or &lt;code&gt;aspect-ratio&lt;/code&gt;) and no content to size from collapses toward zero. The widget doesn't jank anymore. It just silently stops taking up space, and you find out from a bug report, not a profiler.&lt;/p&gt;

&lt;p&gt;The fix is to pair &lt;code&gt;size&lt;/code&gt; containment with an explicit size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;280px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* or min-height, or aspect-ratio */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the trade &lt;code&gt;content-visibility: auto&lt;/code&gt; makes for you automatically when an element scrolls off-screen — it's &lt;code&gt;contain: layout style paint&lt;/code&gt;, plus &lt;code&gt;size&lt;/code&gt; containment kicked in conditionally, which is why &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility" rel="noopener noreferrer"&gt;that property&lt;/a&gt; asks you to set a &lt;code&gt;contain-intrinsic-size&lt;/code&gt; placeholder. &lt;code&gt;contain&lt;/code&gt; on its own gives you the same lever with none of the training wheels: you decide which boxes get which guarantees, and you're responsible for the size one.&lt;/p&gt;

&lt;p&gt;One more side effect worth knowing before you add this everywhere: &lt;code&gt;layout&lt;/code&gt;, &lt;code&gt;paint&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt;, and &lt;code&gt;strict&lt;/code&gt; all turn the element into a new &lt;strong&gt;containing block&lt;/strong&gt; for absolutely- and fixed-positioned descendants, and a new &lt;strong&gt;stacking context&lt;/strong&gt;. A child using &lt;code&gt;position: fixed&lt;/code&gt; to escape to the viewport, or a dropdown relying on &lt;code&gt;position: absolute&lt;/code&gt; against some ancestor further up the tree, will suddenly anchor to your newly-contained box instead. That's rarely what breaks a dashboard widget — but it's exactly what breaks a modal or a tooltip if you reach for &lt;code&gt;contain&lt;/code&gt; on a wrapper without checking what's positioned relative to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When plain, uncontained CSS is still the right call
&lt;/h2&gt;

&lt;p&gt;Most elements on most pages don't need this. A static hero section, a footer, a paragraph of text — none of it changes after paint, so there's nothing for containment to isolate it from. Containment earns its keep specifically where something &lt;strong&gt;changes on its own schedule&lt;/strong&gt; independent of the rest of the page: live counters, chat widgets, auto-refreshing tickers, anything driven by a timer or a socket rather than user navigation. If you haven't opened the Performance tab and actually seen a Layout block wider than the element that triggered it, you don't have evidence containment will help — you have a hunch, and &lt;code&gt;contain: strict&lt;/code&gt; on a hunch is how an element quietly collapses to zero height.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The layout engine isn't being wasteful when it re-checks more than you expected — it's being correct, given that it has no way to know your widget is self-contained. &lt;code&gt;contain&lt;/code&gt; is how you tell it. Scope it to the one element that changes on its own, pick &lt;code&gt;layout paint&lt;/code&gt; (or &lt;code&gt;content&lt;/code&gt;) first, and only reach for &lt;code&gt;size&lt;/code&gt; / &lt;code&gt;strict&lt;/code&gt; once that element has an explicit size to fall back on. The profiler cost that looked like a JavaScript problem was a missing boundary the whole time.&lt;/p&gt;

&lt;p&gt;Go record a profile on the one thing on your page that updates without a page reload — a counter, a ticker, a live status dot — and check whether the Layout block is scoped to it. What did you find?&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/css-contain-rendering-isolation/quiz" rel="noopener noreferrer"&gt;Take the 8-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>css</category>
      <category>performance</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Stop Electing a Leader Tab. SharedWorker Just Holds the Connection.</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:10:31 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-electing-a-leader-tab-sharedworker-just-holds-the-connection-1623</link>
      <guid>https://dev.to/parsajiravand/stop-electing-a-leader-tab-sharedworker-just-holds-the-connection-1623</guid>
      <description>&lt;p&gt;Last week's fix for "six tabs, six WebSockets" was &lt;code&gt;navigator.locks&lt;/code&gt;: elect one tab as leader, let it hold the one real connection, relay everything to the rest over &lt;code&gt;BroadcastChannel&lt;/code&gt;. It works. I said as much at the time.&lt;/p&gt;

&lt;p&gt;It also has a seam I didn't dwell on: &lt;em&gt;the connection lives inside a tab.&lt;/em&gt; Close that tab — the user's doing, not a bug — and the lock releases, a new leader gets picked within the same event loop turn, and that new leader still has to open a brand-new &lt;code&gt;WebSocket&lt;/code&gt; from scratch. Real handshake, real round trip, real gap before updates resume. For a dashboard, nobody notices. For a live price ticker or a shared cursor, that gap is the whole ballgame.&lt;/p&gt;

&lt;p&gt;There's a version of this where no tab ever owns the connection, so no tab closing can take it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The belief: something has to own the socket
&lt;/h2&gt;

&lt;p&gt;The leader-election pattern isn't wrong. It's the correct answer to a real constraint: a &lt;code&gt;WebSocket&lt;/code&gt; object lives in &lt;em&gt;some&lt;/em&gt; JavaScript execution context, and until recently the only context every tab could reliably reach was another tab's. So you pick one, crown it leader, and accept that leadership — like any job held by one person — has a handoff cost when that person leaves.&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="c1"&gt;// what every tab currently runs&lt;/span&gt;
&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-connection-leader&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://example.com/live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&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="c1"&gt;// held until this tab closes&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine code. It's also solving "how do we share one connection" by picking which &lt;em&gt;page&lt;/em&gt; gets to be the exception — which means the exception goes away the moment that page does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context that outlives every tab
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;SharedWorker&lt;/code&gt; is a separate execution context, like a regular &lt;code&gt;Worker&lt;/code&gt;, except more than one tab can connect to the &lt;em&gt;same instance&lt;/em&gt; of it at once. The browser starts it the first time any tab asks for it by URL, keeps it running as long as at least one tab is still connected, and every tab talks to it through its own &lt;code&gt;MessagePort&lt;/code&gt; — a direct, two-way pipe into one shared scope that was never inside any of their tabs to begin with.&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="c1"&gt;// live-feed-worker.js&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onconnect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ports&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="nx"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&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="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://example.com/live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// tab -&amp;gt; server, through the one socket&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// every tab, on load&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SharedWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/live-feed-worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;renderUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole wiring. No election, because there's nothing to elect a leader &lt;em&gt;for&lt;/em&gt; — the worker opens the socket once, the first time &lt;code&gt;ports.size&lt;/code&gt; goes from zero to one, and every tab after that just gets handed a port into a worker that's already running. Close the tab that happened to open first, second, or fifth: &lt;code&gt;socket&lt;/code&gt; doesn't move, because it was never that tab's to carry.&lt;/p&gt;

&lt;p&gt;One detail worth getting right: setting &lt;code&gt;port.onmessage&lt;/code&gt; directly starts the port automatically. You only need an explicit &lt;code&gt;port.start()&lt;/code&gt; call if you wire the port with &lt;code&gt;addEventListener("message", …)&lt;/code&gt; instead — the implicit start only fires off the property setter.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/sharedworker-shared-tab-connection/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The puzzle this trades in
&lt;/h2&gt;

&lt;p&gt;Removing the leader doesn't remove every problem — it swaps one for a quieter one. &lt;code&gt;navigator.locks&lt;/code&gt; tells you, immediately and for free, the moment a tab is gone: the lock releases the instant the tab closes, crashes, or navigates away, because the browser is the one holding the lock and the browser knows. A &lt;code&gt;SharedWorker&lt;/code&gt; gets no such notice. There is no standard "this port just disconnected" event — a port left behind by a closed tab just sits in your &lt;code&gt;Set&lt;/code&gt;, reachable only by &lt;code&gt;postMessage&lt;/code&gt; calls that go nowhere, until the browser eventually garbage-collects it on its own schedule.&lt;/p&gt;

&lt;p&gt;That's invisible at first — a few dead ports costs you nothing but a wasted loop iteration — but it means you can't use "how many ports do I have" as a live headcount, and you can't reliably ask "is anyone still connected" to decide when to close the real socket. The fix is the same one people have used since shared workers existed: don't wait to be told, say so on the way out.&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="c1"&gt;// every tab&lt;/span&gt;
&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pagehide&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;closing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// live-feed-worker.js&lt;/span&gt;
&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;closing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&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="nx"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;Two extra lines on each side, and now the worker's idea of "who's connected" matches reality closely enough to act on — close the real socket once the last tab says goodbye, instead of leaving it open for a page that's already gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support, honestly
&lt;/h2&gt;

&lt;p&gt;Desktop Chrome, Firefox, and Edge have supported &lt;code&gt;SharedWorker&lt;/code&gt; for a very long time, and Safari added it in version 16 — including iOS Safari 16, which is the gap that used to rule this API out for anything mobile-facing. The one real asterisk: Chrome for Android didn't support &lt;code&gt;SharedWorker&lt;/code&gt; at all for years, specifically because of unpredictable process-lifecycle behavior on mobile, and only gained support in a 2026 release. If a meaningful slice of your traffic is Android Chrome on an older version, check &lt;a href="https://caniuse.com/mdn-api_sharedworker" rel="noopener noreferrer"&gt;caniuse.com's current numbers&lt;/a&gt; against your own analytics before you rely on this being universal — and keep last week's &lt;code&gt;navigator.locks&lt;/code&gt; pattern in your back pocket as the fallback that works everywhere today, Android included.&lt;/p&gt;

&lt;h2&gt;
  
  
  No leader, no handoff, one quieter problem instead
&lt;/h2&gt;

&lt;p&gt;The leader-election pattern isn't obsolete — it's still the one answer that works on every browser shipping today, and it's still the right call if your Android numbers say you can't assume &lt;code&gt;SharedWorker&lt;/code&gt; yet. But where it's available, it removes the actual thing that made leader election necessary: a connection that has to live inside some tab's lifetime. A &lt;code&gt;SharedWorker&lt;/code&gt;'s connection lives in its own context, outside every tab equally, so closing any one of them — including the tab that happened to open it first — costs nothing at all.&lt;/p&gt;

&lt;p&gt;Go check whether your app's "shared" state actually lives independently of every tab, or just inside whichever one got there first. What's the oldest "pick a tab to be special" hack you're still running?&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/sharedworker-shared-tab-connection/quiz" rel="noopener noreferrer"&gt;Take the 7-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/your-timer-doesnt-know-you-tabbed-away-3cjp"&gt;Your Timer Doesn't Know You Tabbed Away&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/stop-using-the-localstorage-hack-to-sync-browser-tabs-broadcastchannel-does-it-natively-4an9"&gt;Stop using the localStorage hack to sync browser tabs. BroadcastChannel does it natively.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/vue-composables-the-shared-state-trap-cheat-sheet-37ia"&gt;Vue Composables: The Shared State Trap (+ Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>performance</category>
    </item>
    <item>
      <title>I Turned 44 Blog Posts Into a Frontend Roadmap You Have to Pass</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Wed, 14 Oct 2026 19:50:47 +0000</pubDate>
      <link>https://dev.to/parsajiravand/i-turned-44-blog-posts-into-a-frontend-roadmap-you-have-to-pass-44ea</link>
      <guid>https://dev.to/parsajiravand/i-turned-44-blog-posts-into-a-frontend-roadmap-you-have-to-pass-44ea</guid>
      <description>&lt;p&gt;It's Sunday night, the interview is on Thursday, and the frontend roadmap in your other tab has more topics than you have evenings. You're sure about half of them and fairly sure about the rest.&lt;/p&gt;

&lt;p&gt;The roadmap can't tell you which "fairly sure" is real. It only knows what you ticked.&lt;/p&gt;

&lt;p&gt;So test the topics before you read them. Here's what that looks like for HTML: ten questions drawn at random, seven right to pass, and the whole stage counts as cleared.&lt;/p&gt;

&lt;p&gt;No account and no reading, just ten questions. Here's why that beats a checkbox, and how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll learn
&lt;/h2&gt;

&lt;p&gt;By the end of this article you'll be able to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explain why a frontend roadmap you tick yourself overstates what you know, using a 2006 study of re-reading versus testing&lt;/li&gt;
&lt;li&gt;Compare seven well-known frontend roadmaps and courses on price, sign-up, checks, skipping and certificates&lt;/li&gt;
&lt;li&gt;Use checkpoints to skip the stages you already know and find the gaps you don't&lt;/li&gt;
&lt;li&gt;Compute learning progress from quiz attempts instead of storing it&lt;/li&gt;
&lt;li&gt;Tell when this path is the wrong tool, and what to use instead&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;You've shipped frontend code for a year or more, in Vue or React, and you want to find out quickly what you actually know: for an interview, a promotion or your own peace of mind. If you build learning tools, read "Under the hood". If you're starting from zero, read "When not to use it" first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why a frontend roadmap you tick yourself stops working&lt;/li&gt;
&lt;li&gt;What people do today: seven frontend roadmaps and courses&lt;/li&gt;
&lt;li&gt;Where today's options break&lt;/li&gt;
&lt;li&gt;The mental model&lt;/li&gt;
&lt;li&gt;Enter the Frontend Developer path&lt;/li&gt;
&lt;li&gt;How the frontend roadmap works, in four steps&lt;/li&gt;
&lt;li&gt;Under the hood: progress computed from quiz attempts&lt;/li&gt;
&lt;li&gt;When not to use it&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet: the path at a glance&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a frontend roadmap you tick yourself stops working
&lt;/h2&gt;

&lt;p&gt;Strip a self-marked roadmap down to its data, and this is all of it:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;learning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;semantic-html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ticked two years ago&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event-loop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// read the article, felt fine&lt;/span&gt;
  &lt;span class="na"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// "I use them every day"&lt;/span&gt;
  &lt;span class="na"&gt;specificity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&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;Every value in that object is an opinion, typed in by the person it describes. Nothing can make it wrong, and that is the bug.&lt;/p&gt;

&lt;p&gt;It fails in three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"done" records reading, not knowing.&lt;/strong&gt; Reaching the end of an article and answering a question about it are different events. The tick records the first one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"skipped" is a guess, in both directions.&lt;/strong&gt; You skip HTML because you've written it for years. Whether you know what &lt;code&gt;inert&lt;/code&gt; does, or what &lt;code&gt;showModal()&lt;/code&gt; gives you that &lt;code&gt;show()&lt;/code&gt; doesn't, the tick can't say. Some of what you skip, you don't know; some of what you re-read, you did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody else can read it.&lt;/strong&gt; A lead, a mentor or an interviewer can't tell your "done" from anyone else's.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix isn't a better checkbox. It's taking the checkbox away from the person being measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  What people do today: seven frontend roadmaps and courses
&lt;/h2&gt;

&lt;p&gt;Here's how the best-known options handle it, checked on their own pages on 14 October 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Free?&lt;/th&gt;
&lt;th&gt;Account&lt;/th&gt;
&lt;th&gt;How your learning is checked&lt;/th&gt;
&lt;th&gt;Skip what you know&lt;/th&gt;
&lt;th&gt;Certificate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://roadmap.sh/frontend" rel="noopener noreferrer"&gt;roadmap.sh&lt;/a&gt; frontend roadmap&lt;/td&gt;
&lt;td&gt;Yes; AI quizzes: 2 on the free plan, unlimited on Pro ($10/month)&lt;/td&gt;
&lt;td&gt;To save progress&lt;/td&gt;
&lt;td&gt;You mark topics done; AI quizzes and self-graded flashcards sit alongside&lt;/td&gt;
&lt;td&gt;Mark a topic "skipped" yourself&lt;/td&gt;
&lt;td&gt;None found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.freecodecamp.org/learn" rel="noopener noreferrer"&gt;freeCodeCamp&lt;/a&gt; v9 certifications&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;To save progress&lt;/td&gt;
&lt;td&gt;Questions after every lesson, 20-question module quizzes, 5 projects, then a 50-question closed-book exam&lt;/td&gt;
&lt;td&gt;No placement test found&lt;/td&gt;
&lt;td&gt;Yes, free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.theodinproject.com/" rel="noopener noreferrer"&gt;The Odin Project&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Optional, to check off lessons&lt;/td&gt;
&lt;td&gt;Projects, compared with other students' solutions&lt;/td&gt;
&lt;td&gt;Cherry-pick lessons and projects&lt;/td&gt;
&lt;td&gt;"We don't provide a certificate"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.mozilla.org/en-US/curriculum/" rel="noopener noreferrer"&gt;MDN Curriculum&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not graded: a syllabus with recommended resources, plus "Test your skills" self-checks in MDN's Learn modules&lt;/td&gt;
&lt;td&gt;A self-paced reading list&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.codecademy.com/learn/paths/front-end-engineer-career-path" rel="noopener noreferrer"&gt;Codecademy Front-End Engineer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;No: Pro, from $15.99/month billed annually&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;103 quizzes, 77 projects, certification exams&lt;/td&gt;
&lt;td&gt;"Skip ahead to the exam"&lt;/td&gt;
&lt;td&gt;Professional certification (Pro)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://scrimba.com/frontend-path-c0j" rel="noopener noreferrer"&gt;Scrimba Frontend Developer path&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;No: subscription&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Coding challenges inside the screencasts, solo projects, some quizzes&lt;/td&gt;
&lt;td&gt;None found&lt;/td&gt;
&lt;td&gt;Certificate of completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.frontendmentor.io/learning-paths" rel="noopener noreferrer"&gt;Frontend Mentor learning paths&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Mostly&lt;/td&gt;
&lt;td&gt;To submit (GitHub login)&lt;/td&gt;
&lt;td&gt;Feedback on what you build: the community, plus AI review (1 a month free, every solution on Pro)&lt;/td&gt;
&lt;td&gt;Pick any challenge&lt;/td&gt;
&lt;td&gt;None found&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these deserve more than a row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;roadmap.sh is the best map of what to learn.&lt;/strong&gt; Its roadmaps are free, the project has 369K GitHub stars, and the site counts more than 3.2 million registered users. If you want to know what exists, look there first; nothing here argues against it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;freeCodeCamp's v9 certifications are the most rigorous free credential in that table, and more rigorous than the quizzes in this article.&lt;/strong&gt; The &lt;a href="https://www.freecodecamp.org/news/freecodecamps-new-responsive-web-design-certification-is-now-live/" rel="noopener noreferrer"&gt;new Responsive Web Design certification&lt;/a&gt; ends each lesson with three comprehension questions and each module with a 20-question quiz (18 right to pass). Then it asks for five certification projects before a 50-question, closed-book exam: one attempt a week, reviewed by staff before the result is released.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Odin Project is right about proof.&lt;/strong&gt; It gives no certificate because, in its FAQ's words, "Employers will be much more impressed with your amazing personal portfolio of projects." Projects are the strongest evidence anywhere in that table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where today's options break
&lt;/h2&gt;

&lt;p&gt;None of these is broken. The gap only shows when you want four things at once: free, no sign-up to start, a check on every step, and a skip you have to earn.&lt;/p&gt;

&lt;p&gt;On roadmap.sh you mark a topic done. Its AI-generated quizzes sit beside the roadmap rather than gating it: two on the free plan ("These don't reset"), unlimited on Pro at $10 a month. freeCodeCamp checks properly, but it is built to take you from the start: its own figure is 300 hours per certification, and the exam waits for five projects. Right for learning, heavy for finding out what you already know.&lt;/p&gt;

&lt;p&gt;Codecademy built exactly the skip I wanted ("Don't waste time learning what you already know") and put it behind Pro, from $15.99 a month billed annually. The Odin Project and MDN Curriculum have nothing to pass, Scrimba is paid, and Frontend Mentor checks what you build rather than what you know.&lt;/p&gt;

&lt;p&gt;No single piece of what I built is new; quizzes, skipping, forks and certificates all exist in that table. What I couldn't find among those seven was all four together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; reading is input, answering is evidence, and progress should be computed from the evidence, never typed in by the person being measured.&lt;/p&gt;

&lt;p&gt;The evidence isn't only for other people. Roediger and Karpicke's 2006 study, &lt;a href="https://doi.org/10.1111/j.1467-9280.2006.01693.x" rel="noopener noreferrer"&gt;&lt;em&gt;Test-Enhanced Learning&lt;/em&gt;&lt;/a&gt; (Psychological Science 17(3), 249–255), compared re-reading with testing. On a test five minutes later, re-reading came out ahead. Two days later, and again a week later, testing did, even though re-reading had left students more confident. No feedback was given in that study: nobody told the tested students what they had missed.&lt;/p&gt;

&lt;p&gt;Three things follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skipping is evidence arriving early.&lt;/strong&gt; If you can pass the check without reading, the reading would have been re-reading, the strategy that lost at two days and at a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check can be open-book and still be a check.&lt;/strong&gt; Its job is to make you answer, and to show you exactly where you couldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failing is the useful part.&lt;/strong&gt; A wrong answer that arrives with an explanation is a reading list. A tick never told you anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Enter the Frontend Developer path
&lt;/h2&gt;

&lt;p&gt;I'd already written the articles. Between July and October, 44 of my posts each taught one piece of frontend, from &lt;a href="https://bestpractic.org/blog/dialog-element-native-modal" rel="noopener noreferrer"&gt;the native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;&lt;/a&gt; to the back/forward cache, and every one has a quiz. What they lacked was an order, and a reason to take the quiz.&lt;/p&gt;

&lt;p&gt;So I put them on a map: a &lt;a href="https://bestpractic.org/paths/frontend-developer" rel="noopener noreferrer"&gt;free frontend developer roadmap&lt;/a&gt; on bestpractic.org, where every step is an article and a quiz you have to pass. Read it. Pass it. Unlock the next stage.&lt;/p&gt;

&lt;p&gt;Honestly, in one sentence: &lt;strong&gt;44 existing articles and their 336 quiz questions, in 7 stages that each close with a checkpoint, free, with no account needed to start, about 16 hours by the site's own estimate, and 7 more levels still being written.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🗺️ The map: &lt;a href="https://bestpractic.org/paths/frontend-developer" rel="noopener noreferrer"&gt;https://bestpractic.org/paths/frontend-developer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;⏭️ The HTML checkpoint: &lt;a href="https://bestpractic.org/paths/frontend-developer/checkpoint/html" rel="noopener noreferrer"&gt;https://bestpractic.org/paths/frontend-developer/checkpoint/html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;1️⃣ Level 1, the native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;: &lt;a href="https://bestpractic.org/blog/dialog-element-native-modal?path=frontend-developer" rel="noopener noreferrer"&gt;https://bestpractic.org/blog/dialog-element-native-modal?path=frontend-developer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📚 All learning paths: &lt;a href="https://bestpractic.org/paths" rel="noopener noreferrer"&gt;https://bestpractic.org/paths&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the frontend roadmap works, in four steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: the map
&lt;/h3&gt;

&lt;p&gt;The route has seven stages: HTML, CSS, JavaScript, Vue or React, Nuxt or Next.js, TypeScript, and Ship it, the production stage. The two forks make nine stage cards, each pair sharing one tier of the map: Nuxt follows Vue, Next.js follows React, and TypeScript opens from either side.&lt;/p&gt;

&lt;p&gt;The longest route is 34 levels, through Vue and Nuxt; through React and Next.js it is 33. JavaScript includes &lt;a href="https://bestpractic.org/blog/weekly-javascript-event-loop" rel="noopener noreferrer"&gt;the event loop&lt;/a&gt;, Next.js starts with &lt;a href="https://bestpractic.org/blog/nextjs-weekly-cache-components-explained" rel="noopener noreferrer"&gt;Cache Components&lt;/a&gt;, and TypeScript with &lt;a href="https://bestpractic.org/blog/weekly-typescript-generics" rel="noopener noreferrer"&gt;generics&lt;/a&gt;. Seven more levels show as "Being written": from-zero openers for HTML, JavaScript, Vue and React, then the box model and cascade, Flexbox, and inferred type predicates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; the percentage measures your route, not the whole map. Each tier counts once, so finishing one branch is 100%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: a level is an article plus its quiz
&lt;/h3&gt;

&lt;p&gt;Each level is one of the 44 articles plus its quiz: 6 to 9 questions, four options each. The article's badge says where it sits ("Level 1 of 4 in HTML"). Pass the quiz at 70% to clear the level. Your best score counts, retakes are unlimited, and each cleared level leaves a crystal on the map, gold at 100%.&lt;/p&gt;

&lt;p&gt;Answers stay hidden until you submit. Then every question is explained: why the right option is right and, if you picked another, why yours was wrong. Two-thirds also link to a source, mostly MDN and the frameworks' own docs.&lt;/p&gt;

&lt;p&gt;Levels open one at a time. A locked one is still readable: its drawer offers "Read it anyway", because every article is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; the lock orders the route; it never hides the article. A quiz you passed before its level opened, from the blog, say, still counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: the checkpoint, and how to skip a stage
&lt;/h3&gt;

&lt;p&gt;Every stage ends in a checkpoint: 10 questions drawn at random from that stage's level quizzes, a fresh draw on each attempt, 70% to pass. The last one, Ship it, draws 15. Beat it, and the stage is clear and the next one opens.&lt;/p&gt;

&lt;p&gt;A checkpoint is open from the moment its stage is. That's the skip from the top of this article: "Know HTML? Skip it" goes straight there, and a pass clears HTML without a single level. The levels stay open, so you can go back for the crystals; skipping earns none.&lt;/p&gt;

&lt;p&gt;Fail it, and the review explains every question; the next attempt is a new draw. One honest caveat: a checkpoint has no questions of its own, so if you have just cleared every level in a stage, you have already met every question it can ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; a checkpoint is a placement check built from the stage's own quizzes. It asks the same of someone who read everything and someone who read nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: your run, and the finish line
&lt;/h3&gt;

&lt;p&gt;No account is needed to start: a guest's run is saved on that device for a year, in a cookie set when you first start a quiz. Sign up, and your guest attempts move to your account, run and all.&lt;/p&gt;

&lt;p&gt;The "Your run" card does the arithmetic. Clear level 1 and you're at 3%: one of five units in HTML (four levels plus the checkpoint), averaged over seven tiers. Beat the HTML checkpoint and you're at 14%.&lt;/p&gt;

&lt;p&gt;You finish by beating one checkpoint per tier: seven passes, at least 75 questions. The certificate needs a free account and lives at a public link (&lt;code&gt;/certificates/BP-…&lt;/code&gt;) that anyone can open to check it. It states what it certifies: earned by every checkpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; finishing is defined by checkpoints alone, which is why a skipper and a completionist end up at the same finish line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood: progress computed from quiz attempts
&lt;/h2&gt;

&lt;p&gt;If you build learning tools, this is the part worth taking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progress is computed, not kept.&lt;/strong&gt; The map never reads your progress from a table; every load works it out again from the map and your best attempt at each quiz. The real &lt;code&gt;path-progress.ts&lt;/code&gt; is covered by 13 Vitest cases. This simplified sketch follows the same rules:&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="c1"&gt;// A simplified sketch of path-progress.ts. The real one also places the "You"&lt;/span&gt;
&lt;span class="c1"&gt;// marker, follows the branch you started at a fork, and marks perfect scores.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// forks share a tier: Vue and React are both 4&lt;/span&gt;
  &lt;span class="nl"&gt;unlockedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;      &lt;span class="c1"&gt;// [] for HTML, ["nuxt", "nextjs"] for TypeScript&lt;/span&gt;
  &lt;span class="nl"&gt;checkpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// the checkpoint's quiz id&lt;/span&gt;
  &lt;span class="nl"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)[];&lt;/span&gt; &lt;span class="c1"&gt;// level quiz ids in order; null = "Being written"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;maxScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pathProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Attempt&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Only your best attempt at each quiz counts, wherever you took it.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxScore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;maxScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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="nx"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;levels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;locked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cleared&lt;/span&gt;&lt;span class="dl"&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. A stage opens when ANY stage in its unlockedBy is cleared.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlockedBy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlockedBy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cleared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// 3. Its checkpoint, and nothing else, clears it.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkpoint&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;cleared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Levels open one at a time. A "Being written" level is skipped: it blocks nobody.&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previousPassed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quiz&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&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="nx"&gt;quiz&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quiz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cleared&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousPassed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cleared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;locked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;previousPassed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ok&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="c1"&gt;// 5. Percent along your route: each tier counts once, scored by its best&lt;/span&gt;
  &lt;span class="c1"&gt;//    stage, and a checkpoint is worth one level.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tierScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;ordered&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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="nx"&gt;cleared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;levels&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="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;written&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="nx"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;tierScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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="nx"&gt;cleared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pct&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;That one decision pays for four features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Guests get a working map with no account, because their quiz attempts were already rows, keyed to a cookie.&lt;/li&gt;
&lt;li&gt;A quiz passed on the blog before the path existed counts the day the path does.&lt;/li&gt;
&lt;li&gt;Adding a level needs no backfill.&lt;/li&gt;
&lt;li&gt;Signing up keeps the run, because the run was only ever the attempts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A signed-in run also writes its latest percentage to an enrollment record, after the fact; the map never reads it back. The rest follows the same instinct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The map is the same for everyone; you are fetched.&lt;/strong&gt; The server renders the map without anyone's progress in it, so every visitor gets the same one. Your place on it arrives after hydration, and the default is a newcomer's view, so a first visit never shows padlocks flicking open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking costs nothing.&lt;/strong&gt; The progress endpoint never sets a cookie. The anonymous ID is only set when there is something to save, like a quiz attempt, so a crawler that only looks never gets one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forks are data.&lt;/strong&gt; Each stage lists the stages that open it (&lt;code&gt;unlockedBy&lt;/code&gt;), and the map draws one road per entry: the line, the fork and the join, from one rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkpoints are assembled, not written.&lt;/strong&gt; Each release builds a stage's checkpoint from its level quizzes. They issue no certificate of their own; the path certificate is the proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The catalogue is code.&lt;/strong&gt; A level whose article isn't written yet shows as "Being written" and blocks nobody. Publishing that article and its quiz under the planned slug is all the map needs; the next release picks it up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;p&gt;Here's when this path is the wrong tool, and what to use instead.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You're starting from zero today.&lt;/strong&gt; The from-zero openers aren't written yet, and the first live level, the native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;, assumes you've built a modal before. Start with the &lt;a href="https://developer.mozilla.org/en-US/curriculum/" rel="noopener noreferrer"&gt;MDN Curriculum&lt;/a&gt;, free and beginner-level, or &lt;a href="https://www.freecodecamp.org/learn" rel="noopener noreferrer"&gt;freeCodeCamp&lt;/a&gt; for questions after every lesson, and come back for the checkpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You learn by building.&lt;/strong&gt; A quiz checks understanding, not whether you can ship a page. &lt;a href="https://www.theodinproject.com/" rel="noopener noreferrer"&gt;The Odin Project&lt;/a&gt; is free and project-based, and &lt;a href="https://www.frontendmentor.io/learning-paths" rel="noopener noreferrer"&gt;Frontend Mentor&lt;/a&gt; gives you real designs to build, with feedback on the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a closed-book credential.&lt;/strong&gt; These quizzes are open-book and unsupervised. freeCodeCamp's v9 certifications end in a closed-book exam and cost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You learn from video.&lt;/strong&gt; Every level here is text. &lt;a href="https://scrimba.com/frontend-path-c0j" rel="noopener noreferrer"&gt;Scrimba's Frontend Developer path&lt;/a&gt; is about 81 hours of interactive screencasts, on a paid plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want the whole map of the field.&lt;/strong&gt; This is one path through 44 articles; &lt;a href="https://roadmap.sh/frontend" rel="noopener noreferrer"&gt;roadmap.sh&lt;/a&gt; shows everything around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You work in Angular or Svelte.&lt;/strong&gt; The forks are Vue and React only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You'll switch devices before signing up.&lt;/strong&gt; A guest run lives in one browser, and clearing its cookies loses it. Create the free account first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is this frontend roadmap really free?
&lt;/h3&gt;

&lt;p&gt;Yes. Every article, quiz and checkpoint on the path is free, and so is the account the certificate needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an account to start the frontend roadmap?
&lt;/h3&gt;

&lt;p&gt;No. A guest run is saved on that device for a year. A free account carries it to every device, keeps what you did as a guest, and is required for the certificate.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does the frontend developer roadmap take?
&lt;/h3&gt;

&lt;p&gt;The site estimates about 16 hours for one route; the read-time labels alone add up to 324 minutes on the Vue route and 302 on the React one. Skip everything you can, and the floor is seven checkpoints: 75 questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I learn Vue or React first?
&lt;/h3&gt;

&lt;p&gt;The path doesn't rank them. Both open when you clear JavaScript, and TypeScript opens after either Nuxt or Next.js, so neither branch is a dead end. Pick the one your job, or the job you want, uses; your percentage only counts one branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I skip HTML and CSS?
&lt;/h3&gt;

&lt;p&gt;Yes, if you can pass their checkpoints, 7 of 10 on each. Beat HTML's and CSS opens; beat CSS's and JavaScript opens. A stage only counts once it is open, so you skip in order.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if I fail a checkpoint?
&lt;/h3&gt;

&lt;p&gt;Nothing is lost. The review explains every question you missed, and you can retake it straight away: each attempt is a new random draw, with no cooldown and no attempt limit, and your best result counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the frontend developer certificate worth anything?
&lt;/h3&gt;

&lt;p&gt;It records one thing: that the named account passed one checkpoint per stage at 70% or better, open-book, at least 75 questions in all. Anyone can open its public link to check it. It is not a closed-book credential; freeCodeCamp's is the free one built for that. Treat it as a receipt, not a diploma.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet: the path at a glance
&lt;/h2&gt;

&lt;p&gt;The numbers below come from the live map as of 14 October 2026; "live levels" counts written levels only.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Live levels&lt;/th&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;th&gt;Two example levels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 · HTML&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;, The inert attribute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 · CSS&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;Specificity with &lt;code&gt;:where()&lt;/code&gt;, Viewport units: dvh, svh, lvh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 · JavaScript&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;Closures, The event loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 · Vue (or React)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;ref vs reactive, Composables &amp;amp; shared state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 · React (or Vue)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;Re-render vs remount, React Compiler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 · Nuxt (after Vue)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;useAsyncData keys, Hydration mismatches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 · Next.js (after React)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;Cache Components, proxy.ts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 · TypeScript&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;10 questions, 7 to pass&lt;/td&gt;
&lt;td&gt;Generics, &lt;code&gt;satisfies&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 · Ship it&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;15 questions, 11 to pass&lt;/td&gt;
&lt;td&gt;Core Web Vitals, Web Workers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 44 written levels across both forks, 34 on the Vue route and 33 on the React route, plus 7 being written.&lt;/p&gt;

&lt;p&gt;The rules in five lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;level       article + quiz (6–9 questions). Pass at 70%; your best attempt counts.
order       Levels open one at a time. A locked article is still free to read.
checkpoint  10 random questions from the stage (15 for Ship it). 70% clears it.
skip        A checkpoint is open as soon as its stage is. Beat it early to skip.
finish      One checkpoint per tier. Certificate: free account, public link.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A tick you give yourself records what you read, not what you can answer. Compute progress from answers.&lt;/li&gt;
&lt;li&gt;In Roediger and Karpicke's 2006 study, re-reading won at five minutes; testing won at two days and at a week, with no feedback.&lt;/li&gt;
&lt;li&gt;Skipping is evidence arriving early: pass the check, and the reading was optional.&lt;/li&gt;
&lt;li&gt;Building a learning tool? Store the attempts and derive everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Back to Sunday night, and the roadmap that only knows what you ticked. Spend the first evening on checkpoints instead of chapters. The stages you pass are done, and the ones you fail hand you every missed question, explained: a better reading list than a column of ticks ever was.&lt;/p&gt;

&lt;p&gt;Which checkpoint do you think you could pass without reading anything, and which one would surprise you?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance; every fact checked against the live site and primary sources.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>frontend</category>
      <category>career</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Remote work means someone else is telling your story for you</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Wed, 14 Oct 2026 06:10:59 +0000</pubDate>
      <link>https://dev.to/parsajiravand/remote-work-means-someone-else-is-telling-your-story-for-you-5e54</link>
      <guid>https://dev.to/parsajiravand/remote-work-means-someone-else-is-telling-your-story-for-you-5e54</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;👋 &lt;strong&gt;I'm Parsa Jiravand — I work in IT, and this is Best Practice.&lt;/strong&gt; One article every day, one soft-skills episode every week. It all lives at &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — come join us.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's a Tuesday afternoon somewhere you've never been, in a room with a whiteboard you've never stood in front of, and three or four people are deciding whether you get the promotion this cycle. One of them is your manager. The rest are working entirely off what your manager says, because none of them have ever actually watched you do the job.&lt;/p&gt;

&lt;p&gt;That room exists whether you're remote or not. The difference is what reaches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening
&lt;/h2&gt;

&lt;p&gt;In an office, you get credit by osmosis. Someone walks past while you're debugging the thing nobody else wanted to touch. Someone overhears you explain a tradeoff clearly in a hallway and mentions it later without even meaning to vouch for you — it just stuck. None of that is a system. It's ambient noise, and some fraction of it drifts into the room where decisions get made, without your manager having to do anything on purpose.&lt;/p&gt;

&lt;p&gt;Remote, that channel is just gone. There's no ambient noise to drift anywhere. So the entire account of your work that makes it into that room is the one your manager constructs — consciously, from memory, usually under time pressure, usually compressed into a sentence because that's all the agenda allows for each person. And building an accurate, compelling account of somebody else's quarter, from memory, in thirty seconds, is a specific skill. It is not the same skill as being good at your job, and it's not the same skill as being a good manager generally, either. Some excellent managers are quietly bad at this one narrow thing, and you'd never know it, because the only symptom is that you don't get mentioned.&lt;/p&gt;

&lt;p&gt;So the real situation isn't "remote work hurts your visibility." It's narrower and more useful than that: when you're not in the room, you are depending on someone else's memory and someone else's narration, and that dependency is usually invisible to you until a cycle goes by where it clearly didn't work.&lt;/p&gt;

&lt;p&gt;It also compounds in a way that's easy to miss. One quiet cycle doesn't feel like much — you tell yourself it was a slow quarter, or the timing was off. But the mechanism doesn't reset between cycles. If your manager's account of you was thin in March, it's still the most recent account in their head come September, because nothing replaced it in between. You don't notice the pattern until you look back at two or three cycles in a row and realize the same thing happened each time, for the same reason, and you'd been treating each one as a one-off.&lt;/p&gt;

&lt;p&gt;This is part of why some companies have started pulling people back into buildings — not because the work can't be done remotely, but because this exact gap is expensive for them too, and a desk is a cheaper fix for them than training managers to narrate well. That's a real tradeoff happening at a lot of places right now, and it's worth naming plainly: the office was never solving this problem on purpose, it was solving it as a side effect, and now that some companies don't have that side effect anymore, a few of them are buying it back the blunt way instead of the deliberate way. You don't have to agree with that choice to understand why it's being made.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what do you actually do about it
&lt;/h2&gt;

&lt;p&gt;You can't be in the room. But you can stop leaving the narration entirely to memory.&lt;/p&gt;

&lt;p&gt;The clearest move: before the thing happens — the review, the calibration meeting, the 1:1 where the cycle gets summarized — tell your manager the sentence you'd want them to use. Not a list, one sentence: &lt;em&gt;"Before you go in, can I tell you the one thing from this project I'd most want remembered? Might save you digging for it."&lt;/em&gt; That's it. You're not asking for credit. You're handing someone who already has to improvise a summary the exact line that improvisation would otherwise cost them time to find.&lt;/p&gt;

&lt;p&gt;The second move is timing. Do this before the gap shows up, not after. If you wait until you notice you weren't mentioned, the conversation becomes about repairing an omission, which is a worse conversation for both of you. Send the sentence a week ahead, as part of a normal update, not as a special request: &lt;em&gt;"heads up, the thing I'd flag from this sprint is the retry logic — it's the reason the on-call load dropped, and I'm the only one who has the context on how it's wired."&lt;/em&gt; You're pre-loading the material. Nobody has to remember anything, because it's already written down in their own inbox.&lt;/p&gt;

&lt;p&gt;The third is for when it still doesn't land — you find out after the fact that you weren't mentioned, or mentioned thinly. The instinct is "that's not fair," which is true and also gets you nothing, because it reads as a complaint about an outcome nobody can undo. Ask instead: &lt;em&gt;"Can you tell me how that came across in calibration? I want to understand so I can be clearer next time."&lt;/em&gt; Same frustration, completely different conversation. One asks someone to defend a decision. The other asks them to hand you information. People will give you the second thing far more easily than they'll give you the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes wrong
&lt;/h2&gt;

&lt;p&gt;Send that sentence every two weeks and it stops being a heads-up and starts being a campaign. Managers notice when every update doubles as a resume bullet, and the material that would've worked once starts reading as managing them instead of informing them. Once a quarter, around the actual review cycle, is about as often as this holds its shape. More than that, and you've made your manager's job of advocating for you feel like a performance you're staging for them, which is the opposite of what you want.&lt;/p&gt;

&lt;p&gt;And there's a version of this that doesn't work at all, no matter how well you word it: if your manager doesn't actually attend the room where this gets decided, or attends it but has no real standing there — overloaded, new, themselves on thin ice — then handing them better material doesn't fix anything, because the room isn't weighing your material, it's weighing whether the person saying it carries any weight. You can't write your way around that one. The problem isn't your sentence. It's that the chain you're depending on has a broken link above the part you control.&lt;/p&gt;

&lt;p&gt;There's also a quieter failure mode worth naming: doing this well for someone who then takes the sentence and says nothing with it, every cycle, without ever telling you why. At some point that stops being a narration problem and becomes information about whether this is a manager who will ever spend effort on your behalf, regardless of what you hand them. One or two quiet cycles is the normal noise this piece is about. A pattern across a year is a different, harder thing to sit with, and the fix for it probably isn't a better sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that isn't fair
&lt;/h2&gt;

&lt;p&gt;Some of this is just luck, and pretending otherwise would be dishonest. You got a manager who's a careful narrator of other people's work, or you didn't. You got a manager with real standing in that room, or you got one who's fighting for their own footing. None of that is something you can fix by being better at sending the right sentence at the right time. The sentence helps the part that's reachable. It does nothing for the part that isn't, and the honest thing is to say that plainly instead of implying that enough self-advocacy closes every gap, because it doesn't.&lt;/p&gt;

&lt;p&gt;It's also worth saying that the office genuinely does give people something remote doesn't — that ambient, unearned visibility is real, and it's not nothing. The answer isn't to insist remote and in-office are secretly equal, because for this one specific thing, they aren't. The answer is to do on purpose what the office does by accident: get the sentence into the room through the one channel you actually have, which is the person standing in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monday
&lt;/h2&gt;

&lt;p&gt;Before your next 1:1, send your manager one sentence: the single thing from the last few weeks you'd most want remembered, and why. Not a list, not a summary of everything — one sentence, the one you'd want quoted if someone only got to say one thing about you in a room you'll never see.&lt;/p&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🤝 &lt;strong&gt;Want to make something with us?&lt;/strong&gt; Write a piece, come on the podcast, or bring an idea that should exist — &lt;strong&gt;&lt;a href="https://bestpractic.org/collaborate" rel="noopener noreferrer"&gt;bestpractic.org/collaborate&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;And if this one helped, send it to the person you know who needs it this week. That's what keeps these coming.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>discuss</category>
      <category>watercooler</category>
      <category>productivity</category>
    </item>
    <item>
      <title>You Installed a Library for Numbers PerformanceObserver Already Has.</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Wed, 14 Oct 2026 06:10:27 +0000</pubDate>
      <link>https://dev.to/parsajiravand/you-installed-a-library-for-numbers-performanceobserver-already-has-1a78</link>
      <guid>https://dev.to/parsajiravand/you-installed-a-library-for-numbers-performanceobserver-already-has-1a78</guid>
      <description>&lt;p&gt;Your bundle analyzer flags a new dependency after a routine &lt;code&gt;npm install&lt;/code&gt;. Two kilobytes, not huge, but you go looking anyway. It's &lt;code&gt;web-vitals&lt;/code&gt; — the package half the performance tutorials on the internet tell you to install just so you can &lt;code&gt;console.log&lt;/code&gt; three numbers: LCP, CLS, INP.&lt;/p&gt;

&lt;p&gt;You open its source expecting something clever. It's mostly &lt;code&gt;PerformanceObserver&lt;/code&gt; calls. The exact same API already sitting in your browser, for free, before your bundle even finishes downloading.&lt;/p&gt;

&lt;p&gt;So why does nearly everyone reach for the library first? There's exactly one part of this that's genuinely hard to get right by hand — and it's not the part most people worry about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring LCP by hand takes four lines
&lt;/h2&gt;

&lt;p&gt;Largest Contentful Paint is the easy one. The browser already tracks which element painted the biggest visible chunk of content, and it hands you updates as a better candidate shows up:&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LCP so far:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;largest-contentful-paint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;buffered: true&lt;/code&gt; is the part people forget and then can't figure out why their observer logs nothing — it backfills entries that happened &lt;em&gt;before&lt;/em&gt; this script ran, which for LCP is usually all of them, since your analytics script rarely loads before the hero image paints.&lt;/p&gt;

&lt;p&gt;The browser keeps revising "largest so far" as bigger elements paint in, and stops for good the moment the user scrolls, clicks, or types, or the tab goes hidden — at that point, whatever the last entry said is the final score. &lt;code&gt;entry.element&lt;/code&gt; is the actual DOM node, which is a nice touch for debugging ("oh, the LCP element is the hidden carousel slide, not the headline") — except when it's &lt;code&gt;null&lt;/code&gt;, which happens if the element has already been removed from the DOM by the time your callback runs. (A separate gotcha hits cross-origin images without a &lt;code&gt;Timing-Allow-Origin&lt;/code&gt; header: &lt;code&gt;element&lt;/code&gt; still resolves, but &lt;code&gt;renderTime&lt;/code&gt; reads as &lt;code&gt;0&lt;/code&gt;, so you have to fall back to &lt;code&gt;loadTime&lt;/code&gt; instead — a different field going quiet, not the element.)&lt;/p&gt;

&lt;p&gt;Four lines, one footgun, done. This is not the part &lt;code&gt;web-vitals&lt;/code&gt; is earning its keep on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the naive version lies to you: CLS
&lt;/h2&gt;

&lt;p&gt;Cumulative Layout Shift reports as a &lt;code&gt;layout-shift&lt;/code&gt; entry every time something visible moves without the user asking it to. Each entry has a &lt;code&gt;.value&lt;/code&gt; — how much of the viewport shifted, weighted by distance. The obvious thing to do is sum them:&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;let&lt;/span&gt; &lt;span class="nx"&gt;clsTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hadRecentInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;clsTotal&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CLS so far:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clsTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layout-shift&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;hadRecentInput&lt;/code&gt; is true if the shift happened within 500ms of the user clicking or typing — an accordion that resizes after you click it shouldn't count against you, so the spec excludes it.)&lt;/p&gt;

&lt;p&gt;Ship that on a dashboard that stays open all day, with an ad slot that occasionally reflows, and watch &lt;code&gt;clsTotal&lt;/code&gt; climb to 3, then 6, then double digits by the afternoon. Nothing on the page ever visibly jumped badly — a few small shifts, hours apart. The number is technically "correct" by your own code, and it is nowhere close to what the real metric reports, because real CLS was never meant to be a lifetime total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you scroll:&lt;/strong&gt; what do you think the actual rule is? A cap? A time window? A per-minute budget?&lt;/p&gt;

&lt;p&gt;It's a &lt;em&gt;session window&lt;/em&gt;. Group shifts where the gap since the last one is under 1 second, cap each group at 5 seconds total, and report whichever single window ever added up to the most:&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;let&lt;/span&gt; &lt;span class="nx"&gt;windows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hadRecentInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&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="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;since&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nx"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CLS (session-windowed):&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layout-shift&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A burst of shifts from one bad ad reflow — all within a second or two of each other — stays grouped into one window and still hurts your score, correctly. But five unrelated shifts scattered across an afternoon never combine into one number, because they never do to a real visitor either: nobody experiences "all the jank from today," they experience one moment where the page jumped under their thumb.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/performanceobserver-core-web-vitals/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  INP has the same trap, with an extra twist
&lt;/h2&gt;

&lt;p&gt;The naive move for Interaction to Next Paint is to listen for &lt;code&gt;event&lt;/code&gt; entries and log every &lt;code&gt;duration&lt;/code&gt;:&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&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;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;durationThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tap a button on a touchscreen and this fires three or four times — &lt;code&gt;pointerdown&lt;/code&gt;, &lt;code&gt;pointerup&lt;/code&gt;, &lt;code&gt;click&lt;/code&gt;, sometimes more — for what the user experienced as &lt;em&gt;one&lt;/em&gt; tap. Log each &lt;code&gt;duration&lt;/code&gt; as its own interaction and you've just told your dashboard there were four slow interactions where there was one.&lt;/p&gt;

&lt;p&gt;The fix is grouping: every &lt;code&gt;event&lt;/code&gt; entry belonging to the same physical interaction shares an &lt;code&gt;interactionId&lt;/code&gt;, so you group by that and take the worst duration inside each group — one number per real interaction, not one per DOM event:&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="nx"&gt;interactions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 0 means "not a tracked interaction"&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;interactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&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;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;durationThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even that map isn't the final INP score — the real metric doesn't just report &lt;code&gt;Math.max(...interactions.values())&lt;/code&gt; either. A single freak slow tap (a GC pause at the wrong millisecond) shouldn't define the whole page's score any more than one slow HTTP request should define your p50 latency. The reference implementation tracks a high percentile across the session instead of the single worst value once there have been enough interactions to make a percentile meaningful — which is a few lines of arithmetic you do not want to be debugging for the first time during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what is the library actually for?
&lt;/h2&gt;

&lt;p&gt;Not the measuring — you just watched that happen in nine lines. It's the three things nobody gets right the first time they hand-roll this: the CLS session-windowing, the INP interaction-grouping-plus-percentile, and the edge cases around bfcache restores and visibility-change timing that silently double-report or under-report a metric if you don't special-case them. That's real, fiddly logic, and &lt;code&gt;web-vitals&lt;/code&gt; has had years of bug reports to get it right. If you're sending these numbers to a real-user-monitoring pipeline that someone makes decisions from, use the library for exactly that math.&lt;/p&gt;

&lt;p&gt;Here's the honest caveat, and it's narrower than it would have been a year ago: Safari was the last of the three engines to ship &lt;code&gt;largest-contentful-paint&lt;/code&gt; and interaction-level &lt;code&gt;event&lt;/code&gt; timing (the &lt;code&gt;interactionId&lt;/code&gt; grouping above), adding both in Safari 26.2, December 2025. Firefox isn't new to this — it's had &lt;code&gt;largest-contentful-paint&lt;/code&gt; since version 122 (early 2024) — but it only picked up &lt;code&gt;interactionId&lt;/code&gt; around the same time as Safari, in Firefox 144. With Safari's December 2025 release, all three engines finally agreed on both entry types, which is what pushed web.dev to mark LCP and INP "Baseline: Newly available" at that point. &lt;code&gt;layout-shift&lt;/code&gt; didn't make the trip — Cumulative Layout Shift is still Chromium-only; Firefox's tracking bug for it has sat open since 2021, and Safari 26.2 shipped the other two without it. So CLS is the one metric where neither the raw API nor &lt;code&gt;web-vitals&lt;/code&gt; can produce a number for a Firefox or Safari visitor — the library's job there is still correctness on the browsers that &lt;em&gt;do&lt;/em&gt; report it, not conjuring data from the one that doesn't. Feature-detect before you rely on any of this:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supportedEntryTypes&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layout-shift&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// measure it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// this visitor is on a browser that can't tell you&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/performanceobserver-core-web-vitals/quiz" rel="noopener noreferrer"&gt;Take the 7-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The library isn't buying you the measurement — the browser was always going to hand you that, Chromium or nothing. It's buying you the arithmetic that's easy to get wrong exactly once, in production, where you won't notice until the dashboard looks wrong for a month. Have you ever shipped a CLS or INP number you'd now bet was quietly miscounted? I'd genuinely like to know in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/web-workers-in-javascript-the-complete-guide-5f17"&gt;Web Workers in JavaScript: The Complete Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/your-lighthouse-score-is-100-long-animation-frames-explain-the-jank-1fip"&gt;Your Lighthouse Score Is 100. Long Animation Frames Explain the Jank.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/shadow-dom-needed-javascript-to-exist-now-it-doesnt-2lla"&gt;Shadow DOM Needed JavaScript to Exist. Now It Doesn't.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your Tabs Each Open Their Own WebSocket. navigator.locks Elects One.</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:11:00 +0000</pubDate>
      <link>https://dev.to/parsajiravand/your-tabs-each-open-their-own-websocket-navigatorlocks-elects-one-24gh</link>
      <guid>https://dev.to/parsajiravand/your-tabs-each-open-their-own-websocket-navigatorlocks-elects-one-24gh</guid>
      <description>&lt;p&gt;Open your app in six tabs — most people have at least that many of something open right now — and check your server's connection count. If you're using WebSockets or Server-Sent Events for live updates, it just went up by six. Six sockets, six keep-alives, six copies of every push notification landing in the same browser window.&lt;/p&gt;

&lt;p&gt;Nobody asked for six connections. They asked to see their dashboard update live. You gave them that, and also a server bill and a battery drain that scale with how many tabs a bookmark-happy user happens to have open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug you can see in your own network tab
&lt;/h2&gt;

&lt;p&gt;Open devtools, go to the Network tab, filter by WS, and open the same page twice. You'll see two independent socket connections, each sending its own ping, each receiving its own copy of every broadcast the server sends.&lt;/p&gt;

&lt;p&gt;Now open six. Your server — which probably caps concurrent connections per deploy, or bills per connection-minute — just got six times the load from one human being. And when the server pushes a notification, that human sees it pop up six times, once per tab, because each tab's JavaScript has no idea the other five exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that doesn't fix it
&lt;/h2&gt;

&lt;p&gt;The obvious instinct is the Page Visibility API: pause the connection in background tabs, since the user is only looking at one of them anyway.&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&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;=&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;This helps a little and breaks something else. A user with the dashboard open on a second monitor, in a visible-but-not-active tab, stops getting updates. Worse, "hidden" and "pointless" aren't the same thing — a background tab doing the actual relaying for a visible one is exactly what you want, not what you're trying to kill.&lt;/p&gt;

&lt;p&gt;The real problem was never "which tabs should connect." It's that &lt;strong&gt;six tabs are duplicating work that only needs to happen once.&lt;/strong&gt; You don't want fewer connections scattered unpredictably across whichever tabs happen to be visible — you want exactly &lt;strong&gt;one&lt;/strong&gt; connection, owned by exactly one tab, with the other five just listening in.&lt;/p&gt;

&lt;p&gt;That's leader election, and the browser already ships the primitive for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  navigator.locks, held for the life of a tab
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;navigator.locks.request()&lt;/code&gt; normally looks like a mutex: acquire a named lock, do some work, let it resolve, the lock releases. But nothing requires the callback to resolve quickly. Give it a promise that never resolves on its own, and the lock stays held for as long as that tab is open — the moment the tab closes, crashes, or navigates away, the browser releases the lock for you, automatically, no cleanup code required.&lt;/p&gt;

&lt;p&gt;That's the whole mechanism. Whichever tab's &lt;code&gt;request()&lt;/code&gt; call wins the (exclusive, FIFO) queue becomes the leader. Every other tab's request just waits — and the instant the leader's tab closes, the next one in line is granted the lock and takes over.&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="nx"&gt;LEADER_LOCK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-connection-leader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LEADER_LOCK&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Only one tab's callback ever runs this at a time.&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wss://example.com/live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// fan out to every other tab&lt;/span&gt;
    &lt;span class="nf"&gt;renderUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                  &lt;span class="c1"&gt;// and render here too — see why below&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&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="c1"&gt;// never resolves — holds the lock until this tab dies&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/tab-leader-election-weblocks-broadcastchannel/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Relaying to the followers
&lt;/h2&gt;

&lt;p&gt;Electing a leader only solves half the problem — the other five tabs still need the updates. That's what &lt;code&gt;BroadcastChannel&lt;/code&gt; is for: the leader relays every message it receives onto a channel that every &lt;em&gt;other&lt;/em&gt; tab is already listening to.&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="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BroadcastChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Every follower tab runs this:&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;renderUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the gotcha worth knowing before you build this: a &lt;code&gt;BroadcastChannel&lt;/code&gt; never delivers a message back to the instance that posted it — that's by design, not a bug. So the leader can't just post to the channel and expect its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;onmessage&lt;/code&gt; to fire; that's why the leader's &lt;code&gt;socket.onmessage&lt;/code&gt; above renders directly instead of waiting for an echo that will never arrive. Followers never touch the real connection — they only ever get updates through the channel. If a follower needs to &lt;em&gt;send&lt;/em&gt; something back (a user action, a form submit), it posts that onto the channel too, and the leader is the only one that forwards it to the server. One tab talks to your backend. The rest just listen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the leader closes
&lt;/h2&gt;

&lt;p&gt;This is the part worth testing yourself, because it's the part that makes this worth doing at all: close the leader tab, and one of the waiting tabs is granted the lock as soon as the browser notices that tab's context is gone — no polling, no "is anyone still there" timeout, no stale leader lingering for thirty seconds before a new one takes over. The browser's lock queue &lt;em&gt;is&lt;/em&gt; the failover mechanism — you don't write one.&lt;/p&gt;

&lt;p&gt;There's a brief gap you should know about: the new leader has to open its own &lt;code&gt;WebSocket&lt;/code&gt; connection after acquiring the lock, so there's a reconnect delay (normal connection-establishment time) before live updates resume. For a dashboard or notification feed, that's invisible. For something that can't tolerate a gap at all, keep that in mind rather than assuming the handoff is instant.&lt;/p&gt;

&lt;p&gt;One more option worth knowing: &lt;code&gt;navigator.locks.request(name, { ifAvailable: true }, callback)&lt;/code&gt; checks leadership without joining the queue — &lt;code&gt;callback&lt;/code&gt; runs immediately with &lt;code&gt;null&lt;/code&gt; if someone else already holds the lock, instead of waiting. Useful for a tab that wants to know "am I the leader?" without volunteering to become one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it with real tabs, not a mental model
&lt;/h2&gt;

&lt;p&gt;Everything above is easy to agree with and easy to get wrong in practice — the FIFO ordering, the automatic release, the reconnect gap. The playground above runs the exact same &lt;code&gt;navigator.locks&lt;/code&gt; + &lt;code&gt;BroadcastChannel&lt;/code&gt; code in three simulated tabs on one page. Open each one, watch the lock queue hand off leadership the moment you close the leader, and try the "steal" option to see what happens when a tab forces its way to the front instead of waiting in line.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/tab-leader-election-weblocks-broadcastchannel/quiz" rel="noopener noreferrer"&gt;Take the 7-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support, honestly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;navigator.locks&lt;/code&gt; is Baseline widely available — Chrome 69+, Firefox 96+, Safari 15.4+ — so this works in every current browser. The one footgun is the &lt;code&gt;steal&lt;/code&gt; option: it forcibly yanks the lock away from whoever holds it, skipping the queue entirely. That's occasionally the right tool (a user force-reloading a stuck tab), but use it knowing the previous leader never gets a chance to finish cleanly — it just loses the lock mid-flight.&lt;/p&gt;

&lt;p&gt;You don't need six tabs each holding their own connection to do the job of one. You need one tab doing the job, and a browser API that already knows how to pick which one — and hand the job to the next tab the moment the first one leaves.&lt;/p&gt;

&lt;p&gt;Does your app open a connection per tab right now? Go count it in the Network panel — I'll bet it's more than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/your-timer-doesnt-know-you-tabbed-away-3cjp"&gt;Your Timer Doesn't Know You Tabbed Away&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/stop-using-the-localstorage-hack-to-sync-browser-tabs-broadcastchannel-does-it-natively-4an9"&gt;Stop using the localStorage hack to sync browser tabs. BroadcastChannel does it natively.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/five-tabs-open-one-refresh-token-the-race-nobody-noticed-1dn8"&gt;Five tabs open, one refresh token — the race nobody noticed&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>performance</category>
    </item>
    <item>
      <title>Next.js 16 Image: priority Deprecated, quality Now Allowlisted</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:10:29 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nextjs-16-image-priority-deprecated-quality-now-allowlisted-1a06</link>
      <guid>https://dev.to/parsajiravand/nextjs-16-image-priority-deprecated-quality-now-allowlisted-1a06</guid>
      <description>&lt;p&gt;Your team ships a product page with a hero image: &lt;code&gt;&amp;lt;Image src={photo} quality={90} priority fill /&amp;gt;&lt;/code&gt;. It looked right in Next.js 15. You upgrade to 16, run a visual regression pass out of habit, and the hero image is... a little softer. Nothing crashed. No error in the terminal. No red line in CI. The &lt;code&gt;quality={90}&lt;/code&gt; prop is still sitting right there in the JSX, doing nothing, and you'd have no way to know unless you happened to run the dev server and read a console warning that most people's eyes skip past.&lt;/p&gt;

&lt;p&gt;That's the real story behind two changes Next.js 16 made to &lt;code&gt;next/image&lt;/code&gt;: the &lt;code&gt;quality&lt;/code&gt; prop is now checked against an allowlist (&lt;code&gt;images.qualities&lt;/code&gt;, defaulting to &lt;code&gt;[75]&lt;/code&gt;), and the &lt;code&gt;priority&lt;/code&gt; prop is deprecated in favor of a new &lt;code&gt;preload&lt;/code&gt; prop. Neither one is flashy enough to make a release-notes headline, and neither one throws a build error, which is exactly why they're worth a proper look: this article is written against &lt;strong&gt;Next.js 16.4&lt;/strong&gt; (verified against the framework's own &lt;code&gt;next/image&lt;/code&gt; and &lt;code&gt;next.config.js&lt;/code&gt; reference docs, and its source in &lt;code&gt;packages/next/src/shared/lib&lt;/code&gt;, in October 2026), and by the end you'll know precisely what changed, why, and how to fix it on purpose instead of by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll learn
&lt;/h2&gt;

&lt;p&gt;By the end of this article you'll be able to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explain what &lt;code&gt;images.qualities&lt;/code&gt; does, why it defaults to &lt;code&gt;[75]&lt;/code&gt; in Next.js 16, and what happens to a &lt;code&gt;quality&lt;/code&gt; value that isn't on the list&lt;/li&gt;
&lt;li&gt;Predict whether a given &lt;code&gt;quality&lt;/code&gt; prop gets silently coerced or causes a hard 400, and why those are two different code paths&lt;/li&gt;
&lt;li&gt;Migrate &lt;code&gt;priority&lt;/code&gt; to &lt;code&gt;preload&lt;/code&gt; correctly, and understand why Next.js changed the name rather than the behavior&lt;/li&gt;
&lt;li&gt;Read the Version History table at the bottom of a Next.js docs page and use it the way this article does&lt;/li&gt;
&lt;li&gt;Configure &lt;code&gt;images.qualities&lt;/code&gt; and &lt;code&gt;preload&lt;/code&gt;/&lt;code&gt;loading&lt;/code&gt;/&lt;code&gt;fetchPriority&lt;/code&gt; together without fighting the framework's own validation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;You've used &lt;code&gt;next/image&lt;/code&gt; in an App Router project — at minimum, you've written &lt;code&gt;&amp;lt;Image src={...} width={...} height={...} /&amp;gt;&lt;/code&gt; and set &lt;code&gt;priority&lt;/code&gt; on a hero image at some point. You don't need prior knowledge of the Image Optimization API internals; we build the model from the prop down to the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The problem: a prop that still compiles but silently does less&lt;/li&gt;
&lt;li&gt;The mental model: your props pass through a config-aware layer first&lt;/li&gt;
&lt;li&gt;Walkthrough: the quality allowlist&lt;/li&gt;
&lt;li&gt;Walkthrough: priority to preload&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a prop that still compiles but silently does less
&lt;/h2&gt;

&lt;p&gt;Here's the Next.js 15 component from the intro, completely ordinary code:&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="c1"&gt;// Works exactly as written on Next.js 15&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Studio monitor on a desk"&lt;/span&gt;
  &lt;span class="na"&gt;fill&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upgrade to Next.js 16 with no other changes and two things happen, neither of which raises an error:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;quality={90}&lt;/code&gt; stops meaning 90.&lt;/strong&gt; Next.js 16 ships a default &lt;code&gt;images.qualities: [75]&lt;/code&gt;. Since 90 isn't on that list, the component silently requests the &lt;em&gt;closest allowed value&lt;/em&gt; instead — which, with the default config, is 75. In development you get a console warning. In a production build, there is no warning at all; the image is just a little softer than the designer approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;priority&lt;/code&gt; is marked &lt;code&gt;@deprecated&lt;/code&gt; in the type definitions&lt;/strong&gt;, and the official docs now describe &lt;code&gt;preload&lt;/code&gt; as the prop to reach for. Nothing breaks. Nothing tells you at runtime, either — unlike some other deprecated &lt;code&gt;Image&lt;/code&gt; props (&lt;code&gt;onLoadingComplete&lt;/code&gt;, for instance, logs a warning telling you to switch to &lt;code&gt;onLoad&lt;/code&gt;), &lt;code&gt;priority&lt;/code&gt; just keeps working, silently, for as long as you keep writing it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both changes share the same shape: a prop you set still "works," in the sense that your code compiles and the page renders, while quietly doing something other than what the Next.js 15-era mental model says it does. That's the gap this article closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: your props pass through a config-aware layer first
&lt;/h2&gt;

&lt;p&gt;It's tempting to think of &lt;code&gt;&amp;lt;Image quality={90} priority /&amp;gt;&lt;/code&gt; as "pass these two numbers/flags to the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag." It never was that simple, and in Next.js 16 it's explicit: both props go through a validation and normalization layer that consults your &lt;code&gt;next.config.js&lt;/code&gt; before anything is requested over the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt;'s props aren't a direct line to the browser. They're &lt;em&gt;inputs to a decision Next.js makes on your behalf&lt;/em&gt;, and that decision depends on config you may not have touched. Quality gets &lt;strong&gt;snapped&lt;/strong&gt; to the nearest value your config allows; priority-style loading urgency gets &lt;strong&gt;normalized&lt;/strong&gt; into one signal. In both cases, the component decides what actually leaves your server — your prop is a request, not a guarantee.&lt;/p&gt;

&lt;p&gt;Two separate decisions fall out of that model, and they're the two things that changed in 16:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What quality number actually gets requested&lt;/strong&gt; — governed by &lt;code&gt;images.qualities&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the image loads eagerly and gets a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt;&lt;/strong&gt; — governed by &lt;code&gt;priority&lt;/code&gt;/&lt;code&gt;preload&lt;/code&gt;/&lt;code&gt;loading&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Walkthrough: the quality allowlist
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stage 1 — the default, unconfigured app
&lt;/h3&gt;

&lt;p&gt;With no &lt;code&gt;images&lt;/code&gt; config at all, Next.js 16's effective default is:&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="c1"&gt;// next.config.js — this is the IMPLICIT default in Next.js 16, you don't have to write it&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;qualities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;75&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;&lt;strong&gt;Key concept:&lt;/strong&gt; every &lt;code&gt;&amp;lt;Image quality={N}&amp;gt;&lt;/code&gt; in your app is now checked against this array. If &lt;code&gt;N&lt;/code&gt; isn't in it, Next.js doesn't throw — it picks whichever configured value is numerically closest to &lt;code&gt;N&lt;/code&gt; and uses that instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2 — what "closest" actually means
&lt;/h3&gt;

&lt;p&gt;This isn't a vague "rounds to a nice number" — it's a specific, small algorithm, and you can reason about it exactly:&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="c1"&gt;// This is Next.js's own logic (shared/lib/find-closest-quality.ts), reproduced for clarity&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findClosestQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;configuredQualities&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="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;75&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="nx"&gt;configuredQualities&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;configuredQualities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cur&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;configuredQualities&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="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;findClosestQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;           &lt;span class="c1"&gt;// → 75 (only option)&lt;/span&gt;
&lt;span class="nf"&gt;findClosestQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;// → 75 (|82-75|=7 beats |82-100|=18)&lt;/span&gt;
&lt;span class="nf"&gt;findClosestQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;// → 100 (|90-100|=10 beats |90-75|=15)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try the exact same function with your own numbers in the playground below — it runs this reduction live, not a simplified stand-in.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/nextjs-weekly-image-priority-quality-changes/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3 — configuring it on purpose
&lt;/h3&gt;

&lt;p&gt;If 75 is too aggressive for a photography-heavy site, widen the allowlist explicitly:&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="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;qualities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;Now &lt;code&gt;quality={90}&lt;/code&gt; on a component is requested as 90, exactly, because 90 is on the list. The fix for the regression in the intro isn't "add quality back" — it was never removed — it's "put 90 in &lt;code&gt;images.qualities&lt;/code&gt;."&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 4 — the two different failure modes
&lt;/h3&gt;

&lt;p&gt;This is the part that trips people up, because there are genuinely two code paths, not one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Through &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; (the normal path):&lt;/strong&gt; a disallowed &lt;code&gt;quality&lt;/code&gt; is coerced to the closest allowed value. No error, no 4xx — your visitor gets a real image, just at a different quality than you asked for. Development logs a console warning naming the exact value to add to your config; production does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Through the raw optimization endpoint&lt;/strong&gt; (&lt;code&gt;GET /_next/image?url=...&amp;amp;w=...&amp;amp;q=90&lt;/code&gt;, the kind of URL you'd get by copying a &lt;code&gt;src&lt;/code&gt; out of dev tools, or by hand-building a request): the server validates &lt;code&gt;q&lt;/code&gt; against the same allowlist and, if it isn't an exact match, returns a &lt;strong&gt;400 Bad Request&lt;/strong&gt;. There's no "closest value" leniency here, because this endpoint isn't going through your component's props at all — it's a public HTTP surface, and Next.js treats an unlisted quality value the same way it treats a malformed width: a bad request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "what happens if the quality isn't allowed" has two honest answers depending on which door you walked through, and both are intentional: the component degrades gracefully for real visitors; the raw endpoint stays strict so the allowlist can't be bypassed by hitting the optimizer directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walkthrough: priority to preload
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stage 1 — what &lt;code&gt;priority&lt;/code&gt; always did
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;priority&lt;/code&gt; on &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; has always done two things together: it stops the image from being treated as lazy-loaded (so the browser doesn't wait to discover it near the viewport), and it has Next.js insert a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; for it, so the browser's preload scanner can start fetching it before React even renders the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag. That combination is exactly what you want for a Largest Contentful Paint (LCP) candidate — your hero image, the above-the-fold product photo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2 — the rename, and why it's a rename and not a behavior change
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js 15 — still compiles on 16, still works, but the type is now @deprecated&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt; &lt;span class="na"&gt;fill&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Next.js 16 — the documented way forward&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="na"&gt;preload&lt;/span&gt; &lt;span class="na"&gt;fill&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; for the common case — "this is my LCP image, load it with urgency" — &lt;code&gt;preload&lt;/code&gt; is a straight swap for &lt;code&gt;priority&lt;/code&gt;. Both disable lazy-loading and both trigger the same preload &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; insertion; Next.js didn't change what the prop &lt;em&gt;does&lt;/em&gt; here, it changed what it's &lt;em&gt;called&lt;/em&gt;, specifically so the name describes the mechanism (a resource preload) instead of a vague notion of importance. You're not supposed to set both: the component throws "Only &lt;code&gt;preload&lt;/code&gt; should be used" if a single &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; has &lt;code&gt;priority&lt;/code&gt; and &lt;code&gt;preload&lt;/code&gt; together — but, like the quality-coercion warning, that check only runs while &lt;code&gt;NODE_ENV !== 'production'&lt;/code&gt;, so it surfaces in &lt;code&gt;next dev&lt;/code&gt;, not in a &lt;code&gt;next build&lt;/code&gt;/&lt;code&gt;next start&lt;/code&gt; production run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3 — reaching past &lt;code&gt;preload&lt;/code&gt; when you need more control
&lt;/h3&gt;

&lt;p&gt;The docs are explicit that &lt;code&gt;preload&lt;/code&gt; isn't always the right tool, even for an above-the-fold image: "In most cases, you should use &lt;code&gt;loading="eager"&lt;/code&gt; or &lt;code&gt;fetchPriority="high"&lt;/code&gt; instead of &lt;code&gt;preload&lt;/code&gt;." Reach for those two directly when:&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="c1"&gt;// Multiple images could plausibly be the LCP element depending on viewport size —&lt;/span&gt;
&lt;span class="c1"&gt;// don't blanket them all with preload (that fights the browser's own prioritization)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"eager"&lt;/span&gt; &lt;span class="na"&gt;fetchPriority&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="na"&gt;fill&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preload&lt;/code&gt; is a blunt, singular signal — "fetch this one early, with a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag." &lt;code&gt;loading&lt;/code&gt;/&lt;code&gt;fetchPriority&lt;/code&gt; let the browser's own scheduler make the call across several candidates, which matters once a layout has more than one image that could end up being the LCP element on different screen sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing warns you to migrate &lt;code&gt;priority&lt;/code&gt;.&lt;/strong&gt; It's &lt;code&gt;@deprecated&lt;/code&gt; in the types (your editor may gray it out or flag it with a linter that reads JSDoc), but there is no runtime console message pointing you to &lt;code&gt;preload&lt;/code&gt;, so code that never gets a type-check pass can carry &lt;code&gt;priority&lt;/code&gt; indefinitely without anyone noticing it's the old name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The production quality warning doesn't exist.&lt;/strong&gt; The &lt;code&gt;images.qualities&lt;/code&gt; mismatch warning only fires in development (&lt;code&gt;NODE_ENV !== 'production'&lt;/code&gt;). A staging environment running a production build will not tell you a &lt;code&gt;quality&lt;/code&gt; prop is being coerced — only &lt;code&gt;next dev&lt;/code&gt; will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A static import's &lt;code&gt;quality&lt;/code&gt; is checked too.&lt;/strong&gt; &lt;code&gt;quality&lt;/code&gt; behaves identically whether &lt;code&gt;src&lt;/code&gt; is a remote URL, a path under &lt;code&gt;public/&lt;/code&gt;, or a statically imported image — the allowlist check doesn't care how the &lt;code&gt;src&lt;/code&gt; arrived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raising the allowlist raises optimizer work.&lt;/strong&gt; Every quality value you add to &lt;code&gt;images.qualities&lt;/code&gt; is a value the Image Optimization API may actually have to render on demand. A wide allowlist (say, every integer 1–100) defeats the point of having one at all — the docs frame the default narrow list as a deliberate guard against letting "malicious actors... optimize more qualities than you intended," since each distinct quality is a distinct image variant your server (or your hosting provider's optimizer) has to produce and cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;priority&lt;/code&gt;/&lt;code&gt;preload&lt;/code&gt; and &lt;code&gt;loading="lazy"&lt;/code&gt; are mutually exclusive, and so are &lt;code&gt;priority&lt;/code&gt; and &lt;code&gt;preload&lt;/code&gt; together.&lt;/strong&gt; The component throws for either combination rather than silently picking one — but that throw lives in the same &lt;code&gt;NODE_ENV !== 'production'&lt;/code&gt;-gated code as the quality warning, so it's a &lt;code&gt;next dev&lt;/code&gt; error, not a guarantee your &lt;code&gt;next build&lt;/code&gt;/CI pipeline will catch it. If you only ever test with a production build, an invalid combination can ship unnoticed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;images.qualities&lt;/code&gt; once, deliberately, in &lt;code&gt;next.config.js&lt;/code&gt;&lt;/strong&gt;, based on the quality values your designs actually use — don't leave it at the 16 default if your app already standardizes on, say, 85 for photography.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserve &lt;code&gt;preload&lt;/code&gt; for the one true LCP candidate&lt;/strong&gt; on a given page. If more than one image could be it depending on viewport, use &lt;code&gt;loading="eager"&lt;/code&gt;/&lt;code&gt;fetchPriority="high"&lt;/code&gt; on the candidates instead and let the browser's scanner decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;next dev&lt;/code&gt; at least once after a major upgrade&lt;/strong&gt;, specifically to read the console for &lt;code&gt;images.qualities&lt;/code&gt; warnings — they don't appear in a production build, and they're the only signal this change produces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for &lt;code&gt;priority&lt;/code&gt; across &lt;code&gt;.tsx&lt;/code&gt; files during a 15→16 upgrade&lt;/strong&gt; and rename to &lt;code&gt;preload&lt;/code&gt; as a mechanical pass; it's a safe, behavior-preserving rename for the common usage described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't widen &lt;code&gt;images.qualities&lt;/code&gt; to "everything"&lt;/strong&gt; as a reflexive fix — pick the two or three values your design system actually ships, the same way you'd pick &lt;code&gt;deviceSizes&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;quality={90}&lt;/code&gt; throw an error in Next.js 16?
&lt;/h3&gt;

&lt;p&gt;No. If 90 isn't in your &lt;code&gt;images.qualities&lt;/code&gt; allowlist, the component silently requests the closest allowed value instead (75, with the default config). No exception, no build failure — just a different number than the one in your JSX. The console warning for this only appears in development.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does hitting &lt;code&gt;/_next/image?...&amp;amp;q=90&lt;/code&gt; directly return a 400 but the component doesn't error?
&lt;/h3&gt;

&lt;p&gt;Because they're different code paths. The &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; component coerces an out-of-range quality to the closest allowed value before building the request — it never sends an invalid &lt;code&gt;q&lt;/code&gt;. The raw optimizer endpoint validates &lt;code&gt;q&lt;/code&gt; as a strict allowlist with no coercion, because it's a public HTTP surface that has to reject bad input rather than guess what you meant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;priority&lt;/code&gt; removed in Next.js 16?
&lt;/h3&gt;

&lt;p&gt;No, it's deprecated, not removed. It still compiles and still behaves the same as &lt;code&gt;preload&lt;/code&gt; for the eager-load-plus-preload-link use case. The Version History table in the official &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; reference records it as deprecated starting in &lt;code&gt;v16.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to migrate &lt;code&gt;priority&lt;/code&gt; to &lt;code&gt;preload&lt;/code&gt; immediately?
&lt;/h3&gt;

&lt;p&gt;Not urgently — there's no runtime warning pushing you to, and the behavior hasn't changed. Treat it like any other mechanical rename: do it the next time you're already touching a file, or as a dedicated sweep during a major-version upgrade, rather than a hotfix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;images.qualities&lt;/code&gt; affect images served through a custom loader?
&lt;/h3&gt;

&lt;p&gt;Only partly. The dev-only console warning for an out-of-allowlist &lt;code&gt;quality&lt;/code&gt; fires regardless of which loader you use. But the actual snap-to-closest-value coercion (&lt;code&gt;findClosestQuality&lt;/code&gt;) is implemented inside Next.js's own &lt;strong&gt;default&lt;/strong&gt; loader, not in a shared layer every loader passes through — a custom &lt;code&gt;loader&lt;/code&gt; function or &lt;code&gt;images.loaderFile&lt;/code&gt; receives the raw &lt;code&gt;quality&lt;/code&gt; prop exactly as written and is responsible for applying (or ignoring) &lt;code&gt;images.qualities&lt;/code&gt; itself. It also isn't going through the &lt;code&gt;/_next/image&lt;/code&gt; endpoint in the typical case, so the strict-400 behavior doesn't apply there either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js — the Next.js 16 defaults, written out explicitly&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;qualities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;        &lt;span class="c1"&gt;// REQUIRED allowlist since v16.0.0; unlisted quality → closest match&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;What actually happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request a quality on the allowlist&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Image quality={75} /&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Requested exactly as given&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request a quality NOT on the allowlist&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;Image quality={90} /&amp;gt;&lt;/code&gt; (allowlist &lt;code&gt;[75]&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Silently coerced to &lt;code&gt;75&lt;/code&gt; (closest match); dev-only console warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Widen what's allowed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;images: { qualities: [50, 75, 100] }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;quality={90}&lt;/code&gt; now resolves to &lt;code&gt;100&lt;/code&gt; (closest), not &lt;code&gt;75&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hit the optimizer directly with a bad quality&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET /_next/image?url=...&amp;amp;w=640&amp;amp;q=90&lt;/code&gt; (not on allowlist)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;400 Bad Request&lt;/strong&gt; — no coercion on this path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mark the LCP / hero image (current)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Image preload fill /&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Eager load + &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; inserted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mark the LCP / hero image (deprecated, still works)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Image priority fill /&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Identical to &lt;code&gt;preload&lt;/code&gt; — no runtime warning to switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple possible LCP candidates&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Image loading="eager" fetchPriority="high" /&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Finer-grained signal than &lt;code&gt;preload&lt;/code&gt;; lets the browser's scanner weigh several images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid combination (&lt;code&gt;next dev&lt;/code&gt;-only error)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;Image priority preload /&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;Image priority loading="lazy" /&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Throws: "Only &lt;code&gt;preload&lt;/code&gt; should be used" / "Only one should be used" — only while &lt;code&gt;NODE_ENV !== 'production'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/nextjs-weekly-image-priority-quality-changes/quiz" rel="noopener noreferrer"&gt;Take the 8-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;images.qualities&lt;/code&gt; defaults to &lt;code&gt;[75]&lt;/code&gt; in Next.js 16.&lt;/strong&gt; Any &lt;code&gt;quality&lt;/code&gt; prop not on your configured allowlist is silently snapped to the closest value that is — gracefully through &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt;, strictly (400) through the raw optimizer endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The coercion warning is development-only.&lt;/strong&gt; A production build will not tell you a quality value got rewritten; the only way to catch it is to run &lt;code&gt;next dev&lt;/code&gt; and read the console, or to simply set the allowlist to match your design system up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;priority&lt;/code&gt; isn't gone, it's renamed to &lt;code&gt;preload&lt;/code&gt;&lt;/strong&gt;, and for the one thing almost everyone uses it for — flagging the LCP image — the swap is behavior-preserving. Nothing nags you to make it, so plan it as a deliberate pass rather than waiting for a warning that will never come.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loading="eager"&lt;/code&gt; + &lt;code&gt;fetchPriority="high"&lt;/code&gt; is the more precise tool&lt;/strong&gt; when a page has more than one plausible LCP candidate; &lt;code&gt;preload&lt;/code&gt; is for the single, known one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You upgrade, the build succeeds, the page looks &lt;em&gt;almost&lt;/em&gt; right — and that gap between "compiles" and "renders exactly as specified" is where both of these changes live. Now that you've seen the allowlist and the rename from the inside, a soft hero image or a silently-working &lt;code&gt;priority&lt;/code&gt; prop stops being a mystery and starts being a one-line fix in &lt;code&gt;next.config.js&lt;/code&gt; or a safe find-and-replace.&lt;/p&gt;

&lt;p&gt;If you've been caught by either of these since upgrading to Next.js 16, or found a case the allowlist handles differently than described here, say so in the comments — these are the two next/image changes with the fewest search results and the most "wait, why did my image get softer" GitHub issues.&lt;/p&gt;

&lt;p&gt;This episode builds on two earlier ones in this series: &lt;a href="https://dev.to/parsajiravand/nextjs-cache-components-explained-with-cheat-sheet-55ob"&gt;Next.js Cache Components Explained&lt;/a&gt; covers the broader caching model &lt;code&gt;next/image&lt;/code&gt; sits alongside, and &lt;a href="https://dev.to/parsajiravand/nextjs-route-handlers-get-stopped-caching-in-15-how-to-cache-in-16-56nb"&gt;Next.js Route Handlers: GET Stopped Caching in 15&lt;/a&gt; is the same "quiet default change with no error message" pattern applied to Route Handlers instead of &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nextjs-cache-components-explained-with-cheat-sheet-55ob"&gt;Next.js Cache Components Explained (with Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nextjs-parallel-intercepting-routes-modals-done-right-41fj"&gt;Next.js Parallel &amp;amp; Intercepting Routes: Modals Done Right&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nextjs-server-actions-mutations-security-cheat-sheet-b63"&gt;Next.js Server Actions: Mutations &amp;amp; Security (Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Vue watch vs watchEffect: The Complete Guide</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:10:53 +0000</pubDate>
      <link>https://dev.to/parsajiravand/vue-watch-vs-watcheffect-the-complete-guide-2f40</link>
      <guid>https://dev.to/parsajiravand/vue-watch-vs-watcheffect-the-complete-guide-2f40</guid>
      <description>&lt;p&gt;You type "vue" into a search box, one letter at a time. Four requests go out — for &lt;code&gt;v&lt;/code&gt;, &lt;code&gt;vu&lt;/code&gt;, &lt;code&gt;vue&lt;/code&gt;, and the deleted-and-retyped &lt;code&gt;vue&lt;/code&gt; again — and the one for &lt;code&gt;v&lt;/code&gt; happens to come back last. The screen ends up showing results for a single letter, on top of a &lt;code&gt;watch()&lt;/code&gt; callback that looks completely correct. Nothing in that callback is wrong. The watcher just never learned that a newer run had already made it obsolete.&lt;/p&gt;

&lt;p&gt;This is the article that fixes that, and along the way answers the question every Vue developer eventually asks: &lt;strong&gt;when do I reach for &lt;code&gt;watch()&lt;/code&gt;, when do I reach for &lt;code&gt;watchEffect()&lt;/code&gt;, and why does my callback keep firing at the wrong moment?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll learn
&lt;/h2&gt;

&lt;p&gt;By the end of this article you'll be able to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explain the real difference between &lt;code&gt;watch()&lt;/code&gt; and &lt;code&gt;watchEffect()&lt;/code&gt; — not "one is explicit, one is automatic," but &lt;em&gt;what that difference costs you&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Pick the correct &lt;code&gt;flush&lt;/code&gt; timing (&lt;code&gt;pre&lt;/code&gt;, &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;sync&lt;/code&gt;) for a watcher that needs to read the DOM&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;onWatcherCleanup&lt;/code&gt; to cancel a stale async response before it overwrites a newer one&lt;/li&gt;
&lt;li&gt;Stop a watcher manually, and know when Vue stops it for you&lt;/li&gt;
&lt;li&gt;Recognize the specific ways both APIs quietly track (or fail to track) the wrong things&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You should already be comfortable with &lt;code&gt;ref()&lt;/code&gt;, &lt;code&gt;reactive()&lt;/code&gt;, and &lt;code&gt;computed()&lt;/code&gt; in the Composition API. If "why does destructuring a &lt;code&gt;reactive()&lt;/code&gt; object lose reactivity" isn't yet second nature, read &lt;a href="https://dev.to/parsajiravand/vue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij"&gt;Vue Reactivity Explained: ref vs reactive&lt;/a&gt; first — this article assumes that mental model and builds on it.&lt;/p&gt;

&lt;p&gt;This is written against &lt;strong&gt;Vue 3.5.x&lt;/strong&gt; (verified September 2026). Version-specific notes (the &lt;code&gt;once&lt;/code&gt; option, &lt;code&gt;onWatcherCleanup&lt;/code&gt;) are called out where they apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The problem: a watcher with no memory&lt;/li&gt;
&lt;li&gt;The mental model: pull vs. push tracking&lt;/li&gt;
&lt;li&gt;Stage 1: the smallest watch()&lt;/li&gt;
&lt;li&gt;Stage 2: the same job with watchEffect()&lt;/li&gt;
&lt;li&gt;Stage 3: multiple sources, and watching inside an object&lt;/li&gt;
&lt;li&gt;Stage 4: immediate, deep, and once&lt;/li&gt;
&lt;li&gt;Stage 5: flush timing — pre, post, sync&lt;/li&gt;
&lt;li&gt;Stage 6: cleanup and the race condition, fixed&lt;/li&gt;
&lt;li&gt;Stopping a watcher&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a watcher with no memory
&lt;/h2&gt;

&lt;p&gt;Here's the search box, written the way it's tempting to write it the first time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&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="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every keystroke fires a new request, and requests do not resolve in the order they were sent. If the request for &lt;code&gt;v&lt;/code&gt; is slow and the request for &lt;code&gt;vue&lt;/code&gt; is fast, &lt;code&gt;vue&lt;/code&gt;'s results land first and &lt;code&gt;v&lt;/code&gt;'s results land &lt;em&gt;after&lt;/em&gt;, silently overwriting them. The callback did exactly what it was written to do; it just never asked "am I still the most recent run?"&lt;/p&gt;

&lt;p&gt;That question is exactly what a watcher's cleanup mechanism answers. Getting there first requires being precise about what &lt;code&gt;watch()&lt;/code&gt; and &lt;code&gt;watchEffect()&lt;/code&gt; actually are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: pull vs. push tracking
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; &lt;code&gt;watch()&lt;/code&gt; is &lt;em&gt;told&lt;/em&gt; what to watch. &lt;code&gt;watchEffect()&lt;/code&gt; &lt;em&gt;discovers&lt;/em&gt; what to watch by running once and recording every reactive value it reads. Everything else — old/new values, laziness, immediacy — falls out of that one distinction.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;watch(source, callback)&lt;/code&gt;&lt;/strong&gt; takes an explicit source (a ref, a getter, a &lt;code&gt;reactive()&lt;/code&gt; object, or an array of them) and a callback. It does &lt;strong&gt;not&lt;/strong&gt; run on creation by default. It runs only when the &lt;em&gt;named&lt;/em&gt; source changes, and it hands the callback both the new and the old value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;watchEffect(fn)&lt;/code&gt;&lt;/strong&gt; takes a single function with no separate source. It runs &lt;strong&gt;immediately&lt;/strong&gt;, once, synchronously (well — see flush timing below) to figure out what it read, and re-runs itself in full whenever any of those dependencies change. There is no "old value" concept, because there's no fixed source to diff against — the whole function reruns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put differently: &lt;code&gt;watch&lt;/code&gt; is a subscription to a specific address. &lt;code&gt;watchEffect&lt;/code&gt; is a subscription to &lt;em&gt;whatever addresses this function happens to visit today&lt;/em&gt; — and it can visit different addresses on different runs, exactly like a &lt;code&gt;computed()&lt;/code&gt; getter does. The dependency-tracking mechanism (a run records every reactive value it reads) is shared across &lt;code&gt;computed&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt;'s getter-form sources, and &lt;code&gt;watchEffect&lt;/code&gt; alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: the smallest watch()
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&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="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldVal&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`count went from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;oldVal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;newVal&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="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// logs: "count went from 0 to 1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; the callback did not run when &lt;code&gt;watch&lt;/code&gt; was declared — only after &lt;code&gt;count&lt;/code&gt; changed. That's the "lazy by default" behavior, and it's the first thing that trips people up coming from &lt;code&gt;watchEffect&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: the same job with watchEffect()
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watchEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&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="nf"&gt;watchEffect&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`count is now &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="c1"&gt;// logs immediately: "count is now 0"&lt;/span&gt;

&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// logs: "count is now 1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;watchEffect&lt;/code&gt; ran once immediately, on creation, before anything changed — because it has to run once just to discover that &lt;code&gt;count&lt;/code&gt; is a dependency. &lt;code&gt;watch&lt;/code&gt; never needed to run to know its source; you named it.&lt;/p&gt;

&lt;p&gt;If the job is "sync this state to something external, right now and on every future change" — a document title, a &lt;code&gt;localStorage&lt;/code&gt; key, an analytics call — &lt;code&gt;watchEffect&lt;/code&gt; is usually the shorter answer. Need the &lt;em&gt;old&lt;/em&gt; value, or need to stay silent until one specific thing changes? Reach for &lt;code&gt;watch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: multiple sources, and watching inside an object
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;watch&lt;/code&gt; accepts an array of sources, and hands the callback matching arrays of new/old values:&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="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;newFirst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newLast&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;oldFirst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldLast&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;oldFirst&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="nx"&gt;oldLast&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;newFirst&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="nx"&gt;newLast&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watching a single property of a &lt;code&gt;reactive()&lt;/code&gt; object requires a &lt;strong&gt;getter function&lt;/strong&gt;, not the bare property — because &lt;code&gt;reactive().someProp&lt;/code&gt; is just a plain value the instant you read it out of the object, with no reference back to the proxy:&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;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;count&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Wrong: this is a snapshot number, not a reactive source.&lt;/span&gt;
&lt;span class="c1"&gt;// watch(state.count, cb) never fires.&lt;/span&gt;

&lt;span class="c1"&gt;// Correct: a getter re-reads the proxy every time Vue checks it.&lt;/span&gt;
&lt;span class="nf"&gt;watch&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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldCount&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;This is the same "reactivity is a subscription made at read time" rule from the &lt;code&gt;ref&lt;/code&gt; vs &lt;code&gt;reactive&lt;/code&gt; article — &lt;code&gt;watch&lt;/code&gt; needs something it can re-read, and a getter is how you hand it that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: immediate, deep, and once
&lt;/h2&gt;

&lt;p&gt;Three options on &lt;code&gt;watch&lt;/code&gt; change when and how often it fires (&lt;code&gt;watchEffect&lt;/code&gt; accepts only &lt;code&gt;flush&lt;/code&gt;, covered next, plus the dev-only &lt;code&gt;onTrack&lt;/code&gt;/&lt;code&gt;onTrigger&lt;/code&gt; — it runs on creation and tracks everything it reads, so &lt;code&gt;immediate&lt;/code&gt;, &lt;code&gt;deep&lt;/code&gt; and &lt;code&gt;once&lt;/code&gt; don't apply):&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="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// also run once, right away, with oldValue undefined&lt;/span&gt;
  &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// walk the whole object/array, not just the top-level reference&lt;/span&gt;
  &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// (Vue 3.4+) auto-stop after the callback fires the first time&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deep: true&lt;/code&gt; is the one to use sparingly. Watching a getter that returns an object (&lt;code&gt;() =&amp;gt; state.someObject&lt;/code&gt;) or a &lt;code&gt;ref&lt;/code&gt; holding one only fires when the whole object is replaced (a &lt;code&gt;reactive()&lt;/code&gt; object passed directly is already implicitly deep); &lt;code&gt;deep&lt;/code&gt; walks every nested property so a change three levels down also fires — at the cost of Vue actually traversing the structure to set that tracking up. On a large or deeply nested object, that traversal is real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 5: flush timing — pre, post, sync
&lt;/h2&gt;

&lt;p&gt;Both &lt;code&gt;watch&lt;/code&gt; and &lt;code&gt;watchEffect&lt;/code&gt; accept &lt;code&gt;flush&lt;/code&gt;, and it answers a different question than the options above: &lt;em&gt;relative to a component's own re-render, when does this callback run?&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;flush&lt;/code&gt; value&lt;/th&gt;
&lt;th&gt;When it runs&lt;/th&gt;
&lt;th&gt;Default?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'pre'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before the component re-renders&lt;/td&gt;
&lt;td&gt;Yes, for both APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'post'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After the component has re-rendered and the DOM is updated&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'sync'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Synchronously, the instant the dependency is written&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This connects directly to the batching behavior from &lt;a href="https://dev.to/parsajiravand/vue-nexttick-explained-how-dom-updates-are-batched-3gnp"&gt;Vue nextTick Explained&lt;/a&gt;: a &lt;code&gt;pre&lt;/code&gt;-flush watcher (the default) can run &lt;em&gt;before&lt;/em&gt; the DOM reflects the change that triggered it. If your callback needs to measure or read an element that just changed size or content, &lt;code&gt;flush: 'pre'&lt;/code&gt; will read stale layout:&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="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;items&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// With the default 'pre' flush, the DOM here may not yet&lt;/span&gt;
    &lt;span class="c1"&gt;// reflect the new `items` — this measurement can be one step behind.&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;listRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&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="na"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// wait for the DOM update first&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flush: 'sync'&lt;/code&gt; skips Vue's batching queue entirely and runs the instant the dependency is written — useful for invalidating an external cache the moment something changes, but dangerous on sources mutated many times synchronously (e.g. an array filled in a loop), since it fires on every single mutation with no batching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 6: cleanup and the race condition, fixed
&lt;/h2&gt;

&lt;p&gt;Back to the search box. The fix is to give each run a way to say "abort me if a newer run has already started" — and both &lt;code&gt;watch&lt;/code&gt; and &lt;code&gt;watchEffect&lt;/code&gt; give you exactly that, as &lt;code&gt;onWatcherCleanup()&lt;/code&gt; (3.5+), which must be called &lt;strong&gt;synchronously&lt;/strong&gt; during the callback (before any &lt;code&gt;await&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onWatcherCleanup&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// onWatcherCleanup: Vue 3.5+&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// an aborted run is expected, not an error&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;onWatcherCleanup&lt;/code&gt; registers a function that Vue calls &lt;em&gt;right before the next run of this same watcher&lt;/em&gt; — and also when the component unmounts. Type &lt;code&gt;vue&lt;/code&gt;, and the moment the &lt;code&gt;e&lt;/code&gt; keystroke starts a new run, the previous run's cleanup fires first, aborting its in-flight fetch. An aborted request rejects with an &lt;code&gt;AbortError&lt;/code&gt; instead of resolving, so it can never reach &lt;code&gt;results.value&lt;/code&gt; — and the &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; swallows just that expected rejection, which would otherwise surface through Vue's error handler on every superseded keystroke. Order of arrival stops mattering, because only the current, un-aborted request is allowed to write.&lt;/p&gt;

&lt;p&gt;The same mechanism is also passed as the callback's third parameter (the only option before 3.5, and still supported):&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="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldQ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onCleanup&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="c1"&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 two forms differ here: &lt;code&gt;onWatcherCleanup&lt;/code&gt; must be called before the first &lt;code&gt;await&lt;/code&gt; — after it, Vue can't tell which watcher run it belongs to, so dev mode warns and the cleanup is silently dropped. The &lt;code&gt;onCleanup&lt;/code&gt; argument is bound to its watcher, so it isn't subject to that constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping a watcher
&lt;/h2&gt;

&lt;p&gt;Both &lt;code&gt;watch&lt;/code&gt; and &lt;code&gt;watchEffect&lt;/code&gt; return a handle — call it (or &lt;code&gt;.stop()&lt;/code&gt;) to stop; since 3.5 it also has &lt;code&gt;.pause()&lt;/code&gt; / &lt;code&gt;.resume()&lt;/code&gt;:&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;const&lt;/span&gt; &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchEffect&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// this effect will never run again&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You rarely need to call this yourself inside a component: a watcher created &lt;strong&gt;synchronously&lt;/strong&gt; during &lt;code&gt;setup()&lt;/code&gt; or &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; is automatically stopped when the component unmounts; one created later from an async callback (a &lt;code&gt;setTimeout&lt;/code&gt;, a &lt;code&gt;.then&lt;/code&gt;) inside that same &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; is not. You need the manual &lt;code&gt;stop()&lt;/code&gt; mostly for watchers created outside a component's setup — inside a composable that runs after mount, in a store, or in plain JS — where Vue has no unmount to tie it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;await&lt;/code&gt; breaks dependency discovery.&lt;/strong&gt; &lt;code&gt;watchEffect&lt;/code&gt; only tracks reads that happen synchronously before the first &lt;code&gt;await&lt;/code&gt;. Anything read after an &lt;code&gt;await&lt;/code&gt; inside the function is invisible to it and will not trigger a re-run if it changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A getter that reads nothing reactive never fires.&lt;/strong&gt; &lt;code&gt;watch(() =&amp;gt; someObj.plainProperty, cb)&lt;/code&gt; looks correct but never runs if &lt;code&gt;plainProperty&lt;/code&gt; isn't itself reactive — there's nothing for Vue to subscribe to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;watchEffect&lt;/code&gt; can over-track.&lt;/strong&gt; Because it tracks everything the function body reads, a large conditional inside it can pick up dependencies you didn't intend, causing extra re-runs. &lt;code&gt;watch&lt;/code&gt; with an explicit source avoids this by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.push&lt;/code&gt;/&lt;code&gt;.splice&lt;/code&gt; fire a non-deep watcher only when the source is the &lt;code&gt;reactive()&lt;/code&gt; array itself&lt;/strong&gt; (implicitly deep). &lt;code&gt;watch(itemsRef, cb)&lt;/code&gt; on a &lt;code&gt;ref([])&lt;/code&gt;, or a getter like &lt;code&gt;() =&amp;gt; state.items&lt;/code&gt;, tracks only the array reference — in-place mutations need &lt;code&gt;deep: true&lt;/code&gt;, or watch &lt;code&gt;() =&amp;gt; items.value.length&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;immediate: true&lt;/code&gt; hands &lt;code&gt;undefined&lt;/code&gt; as &lt;code&gt;oldValue&lt;/code&gt;&lt;/strong&gt; on that first synthetic run (an empty array &lt;code&gt;[]&lt;/code&gt; when watching an array of sources) — always guard against it if the callback compares old and new.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reach for &lt;code&gt;watchEffect&lt;/code&gt;&lt;/strong&gt; when the job is "keep this side effect in sync with whatever it reads" and you don't need the old value — document titles, &lt;code&gt;localStorage&lt;/code&gt;, imperative DOM syncing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for &lt;code&gt;watch&lt;/code&gt;&lt;/strong&gt; when you need the previous value, need the callback to stay silent until one &lt;em&gt;specific&lt;/em&gt; thing changes (not everything the function happens to read), or want lazy behavior by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default to &lt;code&gt;flush: 'pre'&lt;/code&gt;&lt;/strong&gt; (the default) unless the callback reads the DOM — then use &lt;code&gt;'post'&lt;/code&gt;. Reserve &lt;code&gt;'sync'&lt;/code&gt; for cache-invalidation-style work that must happen before Vue's own batching, and never on a source that's mutated many times in one tick, like an array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always pair an async watcher with cleanup&lt;/strong&gt; the moment more than one run can be in flight at once — a search box, a resource fetched by an ID prop, anything driven by fast user input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid &lt;code&gt;deep: true&lt;/code&gt; on large objects&lt;/strong&gt; if you can watch a specific derived getter instead; the traversal cost is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does watchEffect run on component mount?
&lt;/h3&gt;

&lt;p&gt;It runs when it's created — during &lt;code&gt;setup()&lt;/code&gt;, before mount — synchronously with the default &lt;code&gt;flush: 'pre'&lt;/code&gt;; with &lt;code&gt;flush: 'post'&lt;/code&gt; (&lt;code&gt;watchPostEffect&lt;/code&gt;) even the first run waits until after render. That first run is also how it discovers its dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between watch's deep option and watchEffect?
&lt;/h3&gt;

&lt;p&gt;They're unrelated axes. &lt;code&gt;deep&lt;/code&gt; controls how far into a &lt;em&gt;reactive object&lt;/em&gt; &lt;code&gt;watch&lt;/code&gt; traverses to detect a change. &lt;code&gt;watchEffect&lt;/code&gt; always tracks only what it actually reads during a run, deep or shallow, because it has no fixed source to traverse — it just doesn't re-read nested properties unless your function body does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why isn't my watch callback firing when I mutate a reactive object property?
&lt;/h3&gt;

&lt;p&gt;The most common cause is watching a plain snapshot instead of a getter — see Stage 3. The second most common cause is watching a &lt;code&gt;ref&lt;/code&gt; holding an object and mutating a nested property without &lt;code&gt;deep: true&lt;/code&gt; (the object is reactive, but &lt;code&gt;watch(ref)&lt;/code&gt; only tracks &lt;code&gt;.value&lt;/code&gt;); the ref's own &lt;code&gt;.value&lt;/code&gt; reference hasn't changed, so a shallow watch sees nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop a watcher manually?
&lt;/h3&gt;

&lt;p&gt;Call the function &lt;code&gt;watch&lt;/code&gt;/&lt;code&gt;watchEffect&lt;/code&gt; returns: &lt;code&gt;const stop = watch(...); stop();&lt;/code&gt;. For a watcher created synchronously inside &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;, you rarely need to — unmount does it for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is watchEffect the same as a computed?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;computed()&lt;/code&gt; returns a cached &lt;em&gt;value&lt;/em&gt; meant to be read elsewhere and must stay pure (no side effects). &lt;code&gt;watchEffect&lt;/code&gt; runs a &lt;em&gt;side effect&lt;/em&gt; and returns nothing meant to be read — it's for doing something, not deriving something.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does watch support async callbacks?
&lt;/h3&gt;

&lt;p&gt;Yes — &lt;code&gt;async (newVal, oldVal) =&amp;gt; { ... }&lt;/code&gt; works exactly as shown above. Just remember that &lt;code&gt;oldVal&lt;/code&gt; and &lt;code&gt;newVal&lt;/code&gt; are captured before the &lt;code&gt;await&lt;/code&gt;, and that any &lt;code&gt;onWatcherCleanup()&lt;/code&gt; call must happen before the first &lt;code&gt;await&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onWatcherCleanup&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// watch(): explicit source, lazy by default, gives old + new value&lt;/span&gt;
&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldVal&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;newA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newB&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;oldA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldB&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reactiveObj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oldVal&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// getter for a nested property&lt;/span&gt;

&lt;span class="c1"&gt;// watchEffect(): auto-tracked, runs immediately, no old value&lt;/span&gt;
&lt;span class="nf"&gt;watchEffect&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Options (watch only, except flush which both accept)&lt;/span&gt;
&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// also run once immediately (oldValue is undefined that time)&lt;/span&gt;
  &lt;span class="na"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// traverse nested properties, not just the top reference&lt;/span&gt;
  &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Vue 3.4+: auto-stop after first fire&lt;/span&gt;
  &lt;span class="na"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 'pre' (default) | 'post' (after DOM update) | 'sync' (no batching)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Cleanup: cancel a stale async run before the next one starts (Vue 3.5+)&lt;/span&gt;
&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// before the first await&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&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="c1"&gt;// Stopping&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchEffect&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// manual — usually unnecessary for watchers created synchronously in &amp;lt;script setup&amp;gt;, which auto-stop on unmount&lt;/span&gt;
&lt;span class="c1"&gt;// Vue 3.5+: the handle also has stop.pause() / stop.resume()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;watch&lt;/code&gt; is told its source explicitly and is lazy by default; &lt;code&gt;watchEffect&lt;/code&gt; discovers its sources by running once, immediately, and re-runs the whole function when any of them change.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flush&lt;/code&gt; decides &lt;em&gt;when&lt;/em&gt; relative to the DOM update — &lt;code&gt;'pre'&lt;/code&gt; (default) can run before a DOM change, &lt;code&gt;'post'&lt;/code&gt; waits for it, &lt;code&gt;'sync'&lt;/code&gt; skips batching entirely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onWatcherCleanup&lt;/code&gt; (Vue 3.5+) — or the callback's third &lt;code&gt;onCleanup&lt;/code&gt; argument, the only option on earlier versions — fires right before the next run, which is exactly what stops a slow, stale async response from overwriting a fresher one.&lt;/li&gt;
&lt;li&gt;A watcher created synchronously in &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; stops itself on unmount; one created outside a component's setup needs its returned &lt;code&gt;stop()&lt;/code&gt; called by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The search box from the top of this article isn't really a search-box bug — it's what happens any time a watcher's &lt;em&gt;previous&lt;/em&gt; run outlives its &lt;em&gt;next&lt;/em&gt; run and nobody told it to stop. Once cleanup is a habit rather than an afterthought, that entire category of bug stops happening. What's the last race condition you tracked down that turned out to be a missing cleanup? Drop it in the comments.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/vue-weekly-watch-vs-watcheffect/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/vue-weekly-watch-vs-watcheffect/quiz" rel="noopener noreferrer"&gt;Take the 9-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/vue-composables-the-shared-state-trap-cheat-sheet-37ia"&gt;Vue Composables: The Shared State Trap (+ Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/vue-nexttick-explained-how-dom-updates-are-batched-3gnp"&gt;Vue nextTick Explained: How DOM Updates Are Batched&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/vue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij"&gt;Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Shipped a Fix. My Service Worker Kept Serving the Bug.</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:10:22 +0000</pubDate>
      <link>https://dev.to/parsajiravand/i-shipped-a-fix-my-service-worker-kept-serving-the-bug-na4</link>
      <guid>https://dev.to/parsajiravand/i-shipped-a-fix-my-service-worker-kept-serving-the-bug-na4</guid>
      <description>&lt;p&gt;Three days after a critical fix went live, the support queue still had tickets coming in — all describing the exact bug you'd already closed. Every reply said the same thing: "I refreshed, it's still broken." A few said they'd refreshed twice.&lt;/p&gt;

&lt;p&gt;You pull up the deployed bundle. The fix is there, served from the server, timestamped correctly. You open the site in an incognito window and it works. You open it in your own regular browser, the one you've had open all week, and there's the bug — your bug, the one you already fixed, running code from four days ago.&lt;/p&gt;

&lt;p&gt;Nothing about your deploy was wrong. The thing lying to your users was sitting in their browser, and it was doing exactly what you told it to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix that doesn't fix it
&lt;/h2&gt;

&lt;p&gt;The instinct is "tell them to hard-refresh." Ctrl+Shift+R, clear cache, the works. It helps some of your users and does nothing for the rest, because a service worker isn't the browser's HTTP cache — it's a script that intercepts every &lt;code&gt;fetch&lt;/code&gt; on your origin and decides, in your code, what to answer with. A normal reload doesn't bypass it. A hard refresh sometimes does, depending on browser and whether the service worker itself is also cached. Telling users to fight their own browser is not a fix; it's a workaround you're asking thousands of people to perform by hand, repeatedly, every time you ship.&lt;/p&gt;

&lt;p&gt;The real question isn't "how do we get users to clear their cache." It's "why didn't the new version take over automatically" — and that's a lifecycle question, not a caching question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening
&lt;/h2&gt;

&lt;p&gt;A cache-first strategy looks like this, and it's genuinely the right choice for a lot of assets:&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&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;Check the cache. If it's there, serve it — no network round trip, instant response, works offline. That's the entire appeal of a service worker for a performance-conscious team, and it's why this pattern shows up in so many tutorials. The trap is what "cache-first" means for the one file that tells the browser everything else changed: if &lt;code&gt;index.html&lt;/code&gt; or your main JS bundle is cache-first too, the browser never even asks the network whether a new version exists. It's handed the old answer before the question can be asked.&lt;/p&gt;

&lt;p&gt;So you deploy a new service worker with a fix for this — and it still doesn't help, for a second, separate reason: &lt;strong&gt;a new service worker doesn't take over the instant it's installed.&lt;/strong&gt; The lifecycle is install → waiting → activate, and by design, a waiting service worker sits idle as long as &lt;em&gt;any&lt;/em&gt; tab is still controlled by the old one. Your users don't close all their tabs. Some of them haven't closed that tab since Monday. So the new worker waits, and waits, and the old one keeps answering every request with cache-first confidence.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/service-worker-stale-cache-deploy/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two lines that actually fix it
&lt;/h2&gt;

&lt;p&gt;Getting a new version to take over immediately is two separate opt-ins, and skipping either one leaves the bug half-fixed:&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;install&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skipWaiting&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// don't wait for old tabs to close&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// take control of open tabs right now&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;skipWaiting()&lt;/code&gt; moves the new worker out of "waiting" the moment it finishes installing, instead of parking until every old tab closes. &lt;code&gt;clients.claim()&lt;/code&gt; is the other half: without it, a freshly-activated worker still only controls tabs that load &lt;em&gt;after&lt;/em&gt; it activates — any tab that was already open keeps talking to whichever worker controlled it when it loaded. Call both, and a currently-open tab's very next fetch is answered by the new worker.&lt;/p&gt;

&lt;p&gt;That solves "the new version never takes over." It doesn't solve the first problem — a cache-first &lt;code&gt;index.html&lt;/code&gt; or main bundle will still serve whatever that new worker &lt;em&gt;finds in its cache&lt;/em&gt;, and if the install step cached the old files before the fetch handler takes over, you've just made the stale response arrive faster. The fix there is matching the strategy to the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTML and anything that names your entry bundle&lt;/strong&gt; — network-first, or no caching at all. These are cheap to fetch and they're the one place "slightly stale" becomes "actively wrong."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fingerprinted, hashed assets&lt;/strong&gt; (&lt;code&gt;app.a3f91c.js&lt;/code&gt;) — cache-first is correct and free. The filename changes when the content does, so there's no staleness to worry about; the old file and the new file are simply different URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything in between&lt;/strong&gt; (an API response, a non-hashed icon) — stale-while-revalidate: answer instantly from cache, and kick off a network fetch in the background that updates the cache for &lt;em&gt;next&lt;/em&gt; time.
&lt;/li&gt;
&lt;/ul&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="nx"&gt;CACHE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-cache-v7&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// bump this every deploy&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&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="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&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;That version bump is the detail cache-first tutorials leave out most often. Called the way the fetch handler above calls it — with no &lt;code&gt;cacheName&lt;/code&gt; option — &lt;code&gt;caches.match()&lt;/code&gt; doesn't just check the cache you're currently writing to; it searches &lt;em&gt;every&lt;/em&gt; cache under your origin, oldest first, and returns the first match it finds. Skip the activate-handler cleanup and a stale, un-deleted &lt;code&gt;app-cache-v6&lt;/code&gt; can still win that search over the &lt;code&gt;app-cache-v7&lt;/code&gt; entry sitting right next to it, even though the newer cache already has the file you just deployed. Deleting the old cache on activate isn't just tidiness — it's what guarantees &lt;code&gt;caches.match()&lt;/code&gt; has nothing stale left to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't a service worker bug
&lt;/h2&gt;

&lt;p&gt;None of this is the API misbehaving. A service worker that keeps serving cached content after a deploy is doing precisely what cache-first plus "wait for every tab to close" plus "never evict the old cache" adds up to. The lifecycle exists so a new version can't yank state out from under a tab mid-interaction — that's the feature, not an oversight. The bug is in treating "cache it" as a complete caching strategy, when the strategy actually has three separate knobs: what gets cached, when the new worker takes over, and when the old cache gets deleted. Ship a fix to only one of those and the other two will happily keep serving yesterday's bug to someone who is absolutely certain they refreshed.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/service-worker-stale-cache-deploy/quiz" rel="noopener noreferrer"&gt;Take the 8-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Next time a user says "I refreshed and it's still broken," the first question isn't "did you refresh hard enough." It's which of your three knobs you forgot to turn.&lt;/p&gt;

&lt;p&gt;What's the longest a stale deploy has haunted one of your users before someone thought to check the service worker?&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/your-timer-doesnt-know-you-tabbed-away-3cjp"&gt;Your Timer Doesn't Know You Tabbed Away&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nextjs-cache-components-explained-with-cheat-sheet-55ob"&gt;Next.js Cache Components Explained (with Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/stop-debouncing-resize-the-browser-already-watches-it-1eh6"&gt;Stop Debouncing Resize. The Browser Already Watches It.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>browser</category>
      <category>performance</category>
    </item>
    <item>
      <title>Nuxt 4 Migration: The Breaking Changes That Actually Bite</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:26:26 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nuxt-4-migration-the-breaking-changes-that-actually-bite-6k1</link>
      <guid>https://dev.to/parsajiravand/nuxt-4-migration-the-breaking-changes-that-actually-bite-6k1</guid>
      <description>&lt;p&gt;Nuxt 3 reached end-of-life on July 31, 2026. No more security patches, no more bug fixes, no more compatibility updates — ever, for that line. If your &lt;code&gt;package.json&lt;/code&gt; still says &lt;code&gt;"nuxt": "^3"&lt;/code&gt;, you're not behind on a nice-to-have; you're running unmaintained software in production. And the first time you run &lt;code&gt;npm install nuxt@latest&lt;/code&gt; to fix that, something you didn't touch will break in a way the error message doesn't explain.&lt;/p&gt;

&lt;p&gt;This article is written against &lt;strong&gt;Nuxt 4.6.0&lt;/strong&gt; (verified against the &lt;code&gt;nuxt&lt;/code&gt; package's npm dist-tags in October 2026 — the &lt;code&gt;3x&lt;/code&gt; tag is frozen at 3.21.11, a patch that actually shipped August 5, 2026, five days &lt;em&gt;after&lt;/em&gt; the cutoff; the last release before the cutoff itself was 3.21.10, on July 27, 2026). It walks through what the official Nuxt 4 release notes and upgrade guide actually say breaks, why those specific things were chosen to break, and the staged path that lets you fix each one without a single big-bang rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll learn
&lt;/h2&gt;

&lt;p&gt;By the end of this article you'll be able to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explain &lt;em&gt;why&lt;/em&gt; Nuxt 4 defaults to an &lt;code&gt;app/&lt;/code&gt; directory instead of treating it as cosmetic churn&lt;/li&gt;
&lt;li&gt;Identify the exact conditions under which the new "Singleton Data Fetching Layer" breaks a &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; call that worked fine on Nuxt 3&lt;/li&gt;
&lt;li&gt;Fix the &lt;code&gt;null&lt;/code&gt; → &lt;code&gt;undefined&lt;/code&gt; default change before it silently passes a stale &lt;code&gt;if (data.value === null)&lt;/code&gt; check&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;future.compatibilityVersion: 4&lt;/code&gt; to surface every breaking change while your app is still running on the Nuxt 3 package&lt;/li&gt;
&lt;li&gt;Run the official codemod instead of hand-editing import paths across a whole codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;You have a Nuxt 3 app in production, or you're planning a new one and want to know what "Nuxt 4" actually changes before you commit to it. You don't need prior Nitro or Nuxt-internals knowledge — just familiarity with &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; and a standard &lt;code&gt;nuxt.config.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The problem: the upgrade that isn't a patch bump&lt;/li&gt;
&lt;li&gt;The mental model: Nuxt 4 is about boundaries, not folders&lt;/li&gt;
&lt;li&gt;Stage 1: the directory default, and why back-compat usually saves you&lt;/li&gt;
&lt;li&gt;Stage 2: the Singleton Data Fetching Layer&lt;/li&gt;
&lt;li&gt;Stage 3: null becomes undefined, and shallowRef replaces ref&lt;/li&gt;
&lt;li&gt;Stage 4: the window.&lt;strong&gt;NUXT&lt;/strong&gt; removal and the TypeScript surprise&lt;/li&gt;
&lt;li&gt;Stage 5: the staged migration path&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: the upgrade that isn't a patch bump
&lt;/h2&gt;

&lt;p&gt;Here's the instinct that gets people in trouble: Nuxt 3 minor versions have been safe to bump for years, so &lt;code&gt;nuxt@4&lt;/code&gt; &lt;em&gt;looks&lt;/em&gt; like just the next number in the same sequence. A team runs the upgrade on a Friday afternoon, the dev server boots, the homepage renders, and it ships.&lt;/p&gt;

&lt;p&gt;Monday morning, two unrelated bugs show up:&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="c1"&gt;// A composable that worked for a year&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAsyncData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;current-user&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// ...later, in a completely different component:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// this branch used to run before the fetch resolved — now it never does&lt;/span&gt;
  &lt;span class="nf"&gt;showGuestBanner&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 guest banner stops appearing for logged-out users on first paint. Nobody touched &lt;code&gt;useAsyncData&lt;/code&gt;. Nobody touched the banner component. The bug is two features away from the code that actually changed, because &lt;code&gt;data&lt;/code&gt; now defaults to &lt;code&gt;undefined&lt;/code&gt;, not &lt;code&gt;null&lt;/code&gt; — and &lt;code&gt;undefined === null&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the shape of almost every Nuxt 4 migration bug: not a loud crash, a quiet behavioral default that moved. The fix for all of them is the same shape too — know the list, grep for it, fix it deliberately — which is what the rest of this article gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: Nuxt 4 is about boundaries, not folders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; every headline Nuxt 4 change is Nuxt drawing a boundary it used to leave implicit, and then enforcing it. Nuxt 3 inferred almost everything from one root directory and a lot of convention; Nuxt 4 makes several of those conventions into contracts that tooling — the file watcher, the TypeScript project, the data layer — can actually rely on.&lt;/p&gt;

&lt;p&gt;Once you see it that way, the "random" list of breaking changes stops being random:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;app/&lt;/code&gt; directory&lt;/strong&gt; isn't a cosmetic rename. It's Nuxt declaring "this subtree is the client application" so your file watcher can ignore &lt;code&gt;node_modules/&lt;/code&gt; and &lt;code&gt;.git/&lt;/code&gt; more aggressively, and your IDE can tell client code from server code by path alone.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Singleton Data Fetching Layer&lt;/strong&gt; isn't a performance tweak. It's Nuxt declaring "a key is one fetch, for real this time" — if two calls share a key, they now &lt;em&gt;must&lt;/em&gt; agree on how that fetch behaves, because they're genuinely the same underlying state, not two independent calls that happen to collide.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;TypeScript changes&lt;/strong&gt; aren't new bugs. They're Nuxt's project references finally being strict enough to surface type errors that were always there, just previously invisible to the compiler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read every section below through that lens: what boundary is this enforcing, and what breaks when my code quietly depended on that boundary being fuzzy?&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: the directory default, and why back-compat usually saves you
&lt;/h2&gt;

&lt;p&gt;Nuxt 4's advertised headline change is that application code — &lt;code&gt;components/&lt;/code&gt;, &lt;code&gt;pages/&lt;/code&gt;, &lt;code&gt;layouts/&lt;/code&gt;, &lt;code&gt;app.vue&lt;/code&gt; — lives under &lt;code&gt;app/&lt;/code&gt; by default, while &lt;code&gt;public/&lt;/code&gt;, &lt;code&gt;shared/&lt;/code&gt;, &lt;code&gt;server/&lt;/code&gt;, and &lt;code&gt;nuxt.config.ts&lt;/code&gt; stay at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-app/
├─ app/
│  ├─ app.vue
│  ├─ components/
│  └─ pages/
├─ server/
├─ public/
└─ nuxt.config.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; if your project already has this shape (most Nuxt 3 starters from the last year or two already do), nothing changes — Nuxt detects the existing layout and keeps it working. This is the part of the migration that generates the most noise online and causes the least real breakage.&lt;/p&gt;

&lt;p&gt;Two situations where it &lt;em&gt;does&lt;/em&gt; bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A custom &lt;code&gt;srcDir&lt;/code&gt;.&lt;/strong&gt; If you've set &lt;code&gt;srcDir&lt;/code&gt; in &lt;code&gt;nuxt.config.ts&lt;/code&gt;, Nuxt 4 resolves &lt;code&gt;modules/&lt;/code&gt;, &lt;code&gt;public/&lt;/code&gt;, &lt;code&gt;shared/&lt;/code&gt;, and &lt;code&gt;server/&lt;/code&gt; from your project root instead of from that custom &lt;code&gt;srcDir&lt;/code&gt; — the opposite of Nuxt 3's behavior. Override it explicitly with &lt;code&gt;dir.modules&lt;/code&gt;, &lt;code&gt;dir.public&lt;/code&gt;, and &lt;code&gt;serverDir&lt;/code&gt; if you need the old resolution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to keep the flat Nuxt 3 layout on purpose.&lt;/strong&gt; Set &lt;code&gt;srcDir: '.'&lt;/code&gt; together with &lt;code&gt;dir.app&lt;/code&gt; in &lt;code&gt;nuxt.config.ts&lt;/code&gt;, and nothing has to move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you do want to adopt the new layout, Nuxt ships a codemod rather than asking you to move files by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx codemod@latest nuxt/4/file-structure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moving files is optional. Everything else in this article is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: the Singleton Data Fetching Layer
&lt;/h2&gt;

&lt;p&gt;This is the change most teams don't see coming, because it looks like &lt;code&gt;useAsyncData&lt;/code&gt; just works differently now rather than looking like a documented breaking change.&lt;/p&gt;

&lt;p&gt;In Nuxt 3, two &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; calls that happened to share a key were already treated as "the same fetch" in practice — &lt;a href="https://dev.to/parsajiravand/useasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1"&gt;the previous episode in this series&lt;/a&gt; covers exactly that sharing behavior and the auto-key bug it causes. Nuxt 4 takes that same-key-means-same-fetch idea and makes it a hard contract: calls sharing a key now &lt;strong&gt;must&lt;/strong&gt; agree on &lt;code&gt;handler&lt;/code&gt;, &lt;code&gt;deep&lt;/code&gt;, &lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;pick&lt;/code&gt;, &lt;code&gt;getCachedData&lt;/code&gt;, &lt;code&gt;serialize&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, and &lt;code&gt;middleware&lt;/code&gt;. Mismatch any of them, and Nuxt logs a development-mode warning — but it doesn't block anything, and you still end up with inconsistent state instead of the two independent results you might expect:&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="c1"&gt;// Component A&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAsyncData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-101&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/products/101&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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="c1"&gt;// Component B — same key, different transform&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAsyncData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-101&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/products/101&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// just the string&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// Whichever call's options "win" depends on render order — this is the bug class.&lt;/span&gt;
&lt;span class="c1"&gt;// Nuxt warns about the mismatch in dev (a console warning, not a thrown error),&lt;/span&gt;
&lt;span class="c1"&gt;// but it doesn't stop the build and it doesn't fix the data — you still have to.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; if two call sites legitimately need the same key, move the call into one shared composable so the options live in exactly one place, instead of being copy-pasted (and drifting) at each call site:&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="c1"&gt;// app/composables/useProduct.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useAsyncData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/products/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&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="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;The other half of this change is &lt;code&gt;getCachedData&lt;/code&gt;. It used to matter mostly on the initial load; in Nuxt 4 it's called on &lt;strong&gt;every&lt;/strong&gt; fetch for that key — including a &lt;code&gt;watch&lt;/code&gt;-triggered refetch or a manual &lt;code&gt;refreshNuxtData()&lt;/code&gt; — and it receives a &lt;code&gt;cause&lt;/code&gt; ('initial' | 'refresh:hook' | 'refresh:manual' | 'watch') so you can decide, per cause, whether to trust the cache:&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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAsyncData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard-stats&lt;/span&gt;&lt;span class="dl"&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/stats&lt;/span&gt;&lt;span class="dl"&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;getCachedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nuxtApp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Always skip the cache on a manual refresh — the whole point was to get fresh data.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cause&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh:manual&lt;/span&gt;&lt;span class="dl"&gt;'&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;undefined&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;nuxtApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&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;h2&gt;
  
  
  Stage 3: null becomes undefined, and shallowRef replaces ref
&lt;/h2&gt;

&lt;p&gt;Two smaller defaults sit next to the data layer change and are easy to miss in a changelog skim:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; now default to &lt;code&gt;undefined&lt;/code&gt;, not &lt;code&gt;null&lt;/code&gt;&lt;/strong&gt;, when a fetch hasn't resolved yet or has no data. Grep your codebase for &lt;code&gt;=== null&lt;/code&gt; and &lt;code&gt;!== null&lt;/code&gt; anywhere near a &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; result — every one of those is a candidate for the exact bug shown in "The problem" above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data&lt;/code&gt; is now a &lt;code&gt;shallowRef&lt;/code&gt;, not a deep &lt;code&gt;ref&lt;/code&gt;.&lt;/strong&gt; Mutating a nested property in place (&lt;code&gt;data.value.items.push(x)&lt;/code&gt;) no longer triggers reactivity — you need to reassign &lt;code&gt;data.value&lt;/code&gt; (or call &lt;code&gt;refresh()&lt;/code&gt;) for the template to update. This trades a small amount of convenience for meaningfully better performance on large payloads, since Nuxt no longer has to deep-observe every field it fetches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are one-line fixes once you know to look for them. Neither throws an error, which is exactly why they're worth grepping for deliberately rather than waiting for a bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: the window.&lt;strong&gt;NUXT&lt;/strong&gt; removal and the TypeScript surprise
&lt;/h2&gt;

&lt;p&gt;One small removal with a mechanical fix: &lt;strong&gt;&lt;code&gt;window.__NUXT__&lt;/code&gt; is removed after hydration completes&lt;/strong&gt;, where it used to linger. Code reading it post-hydration (analytics scripts, debug snippets) needs to capture what it needs earlier — or read the same payload from &lt;code&gt;useNuxtApp().payload&lt;/code&gt; instead, which Nuxt keeps around.&lt;/p&gt;

&lt;p&gt;The bigger surprise isn't a removal at all, and it's opt-in rather than automatic: Nuxt 4 generates separate, context-specific TypeScript project references (&lt;code&gt;tsconfig.app.json&lt;/code&gt;, &lt;code&gt;tsconfig.server.json&lt;/code&gt;, &lt;code&gt;tsconfig.node.json&lt;/code&gt;, &lt;code&gt;tsconfig.shared.json&lt;/code&gt;) alongside the old merged &lt;code&gt;tsconfig.json&lt;/code&gt;, and keeps the old one as the default for backwards compatibility — your existing root &lt;code&gt;tsconfig.json&lt;/code&gt; keeps working, unchanged, until you point it at the new files. &lt;strong&gt;Once you do&lt;/strong&gt; (a fresh Nuxt 4 project scaffolds it this way by default, and the migration codemod can set it up for an existing one), &lt;code&gt;nuxt typecheck&lt;/code&gt; often jumps from zero errors to a page of them. That's not a new Nuxt 4 bug — each context now gets its own, more accurate globals and includes, so TypeScript can finally see code that was always slightly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 5: the staged migration path
&lt;/h2&gt;

&lt;p&gt;The official guide's real advice isn't "bump the package and fix what breaks" — it's to opt into Nuxt 4 behavior &lt;em&gt;while still on the Nuxt 3 package&lt;/em&gt;, so you can fix issues one at a time with a fast feedback loop, before the cutover:&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="c1"&gt;// nuxt.config.ts — still "nuxt": "^3.x" in package.json&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineNuxtConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;future&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;compatibilityVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&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;With that flag set, your Nuxt 3 app runs with Nuxt 4's new defaults — the data layer contract, the &lt;code&gt;null&lt;/code&gt;/&lt;code&gt;undefined&lt;/code&gt; change, the directory resolution — so you see every real breakage in your own code, in your own dev server, without touching your dependency tree yet. Fix what it surfaces, commit, then bump &lt;code&gt;nuxt&lt;/code&gt; itself to &lt;code&gt;^4.0.0&lt;/code&gt; as a final, much smaller step.&lt;/p&gt;

&lt;p&gt;Run the migration recipe codemod once you're ready for the mechanical renames (pin the version — &lt;code&gt;@latest&lt;/code&gt; has a known issue with this one):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx codemod@0.18.7 nuxt/4/migration-recipe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge cases and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Module authors feel this hardest.&lt;/strong&gt; Nuxt 2/Bridge support is fully removed from &lt;code&gt;@nuxt/kit&lt;/code&gt;; check a module's own changelog for Nuxt 4 compatibility before upgrading an app that depends on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;compatibilityDate&lt;/code&gt; needs bumping too.&lt;/strong&gt; An old date pinned in &lt;code&gt;nuxt.config.ts&lt;/code&gt; can keep you on legacy Nitro behavior even after the package upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep-mutating &lt;code&gt;useAsyncData&lt;/code&gt; results in place&lt;/strong&gt; is the most common silent breakage from the &lt;code&gt;shallowRef&lt;/code&gt; change — the data changed, the UI just doesn't notice, which looks like an unrelated rendering bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flip &lt;code&gt;future.compatibilityVersion: 4&lt;/code&gt; before you touch the &lt;code&gt;nuxt&lt;/code&gt; package version&lt;/strong&gt; — the cheapest way to see your own breakage with the smallest blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for &lt;code&gt;=== null&lt;/code&gt; / &lt;code&gt;!== null&lt;/code&gt;&lt;/strong&gt; near every &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; call before you upgrade, not after a bug report finds one for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralize any &lt;code&gt;useAsyncData&lt;/code&gt; call two components legitimately share&lt;/strong&gt; into one composable, so its options can't drift out of sync with the new matching requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you adopt the new project-references &lt;code&gt;tsconfig.json&lt;/code&gt;&lt;/strong&gt; (what a fresh Nuxt 4 project scaffolds, and what the migration codemod can set up for an existing one), run &lt;code&gt;nuxt typecheck&lt;/code&gt; right away and treat what it surfaces as debt you already carried, not a new regression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't move files into &lt;code&gt;app/&lt;/code&gt; as a first step.&lt;/strong&gt; It's the lowest bug-to-effort part of this migration; the data layer and default-value changes are where real bugs hide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I have to move my files into the &lt;code&gt;app/&lt;/code&gt; directory?
&lt;/h3&gt;

&lt;p&gt;No. Nuxt 4 detects an existing Nuxt 3-style layout and keeps it working without any file moves. Moving to &lt;code&gt;app/&lt;/code&gt; is optional, and a codemod (&lt;code&gt;npx codemod@latest nuxt/4/file-structure&lt;/code&gt;) does it for you if you choose to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will my app silently return wrong data after upgrading?
&lt;/h3&gt;

&lt;p&gt;Only if two &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; calls share a key with mismatched options (&lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;deep&lt;/code&gt;, &lt;code&gt;pick&lt;/code&gt;, &lt;code&gt;getCachedData&lt;/code&gt;, &lt;code&gt;serialize&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;middleware&lt;/code&gt;, or the &lt;code&gt;handler&lt;/code&gt; itself). Nuxt does log a development warning when it detects the mismatch, but it's a warning, not a thrown error — it doesn't stop the build, and the wrong data still ships unless you act on it. Centralizing shared-key calls into one composable removes the whole category of bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to upgrade straight from an old Nuxt 3 version?
&lt;/h3&gt;

&lt;p&gt;Yes, but the official guide's recommended path is to first set &lt;code&gt;future.compatibilityVersion: 4&lt;/code&gt; on your current Nuxt 3 app, fix everything it surfaces, and only then bump the &lt;code&gt;nuxt&lt;/code&gt; package itself — rather than doing both at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to Nuxt 3 apps that don't upgrade?
&lt;/h3&gt;

&lt;p&gt;They keep running — npm packages don't disappear — but Nuxt 3 no longer receives security patches, bug fixes, or compatibility updates as of July 31, 2026. That risk grows, silently, the longer it's deferred.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this affect Nuxt modules I depend on, not just my own code?
&lt;/h3&gt;

&lt;p&gt;Yes, independently of your own code. Nuxt 2/Bridge support is removed from &lt;code&gt;@nuxt/kit&lt;/code&gt;, so a module that hasn't been updated for Nuxt 4 may break regardless of anything in this article. Check the module's own changelog before upgrading an app that relies on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Old (Nuxt 3)&lt;/th&gt;
&lt;th&gt;New (Nuxt 4)&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App code location&lt;/td&gt;
&lt;td&gt;flat &lt;code&gt;components/&lt;/code&gt;, &lt;code&gt;pages/&lt;/code&gt;, root &lt;code&gt;app.vue&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app/components/&lt;/code&gt;, &lt;code&gt;app/pages/&lt;/code&gt;, &lt;code&gt;app/app.vue&lt;/code&gt; (back-compat if unchanged)&lt;/td&gt;
&lt;td&gt;Nothing required; &lt;code&gt;npx codemod@latest nuxt/4/file-structure&lt;/code&gt; if you want to move&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;independent-feeling calls, loosely enforced&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;handler&lt;/code&gt;/&lt;code&gt;deep&lt;/code&gt;/&lt;code&gt;transform&lt;/code&gt;/&lt;code&gt;pick&lt;/code&gt;/&lt;code&gt;getCachedData&lt;/code&gt;/&lt;code&gt;serialize&lt;/code&gt;/&lt;code&gt;default&lt;/code&gt;/&lt;code&gt;middleware&lt;/code&gt; must match&lt;/td&gt;
&lt;td&gt;Centralize the call into one composable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getCachedData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mainly consulted on initial load&lt;/td&gt;
&lt;td&gt;called on every fetch, receives &lt;code&gt;{ cause }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Branch on &lt;code&gt;cause&lt;/code&gt; to skip stale cache on manual refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;data&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt; empty value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;undefined&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replace &lt;code&gt;=== null&lt;/code&gt; / &lt;code&gt;!== null&lt;/code&gt; checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;data&lt;/code&gt; reactivity&lt;/td&gt;
&lt;td&gt;deep &lt;code&gt;ref&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;shallowRef&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reassign &lt;code&gt;data.value&lt;/code&gt;, don't deep-mutate in place&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;public/&lt;/code&gt;/&lt;code&gt;assets/&lt;/code&gt; server aliases&lt;/td&gt;
&lt;td&gt;resolved&lt;/td&gt;
&lt;td&gt;removed&lt;/td&gt;
&lt;td&gt;Use explicit paths or asset imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window.__NUXT__&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present after hydration&lt;/td&gt;
&lt;td&gt;removed after hydration&lt;/td&gt;
&lt;td&gt;Read it before hydration completes, if you need it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration path&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;future.compatibilityVersion: 4&lt;/code&gt; on Nuxt 3, then bump the package&lt;/td&gt;
&lt;td&gt;Fix issues with the dependency unchanged first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Nuxt 3 reached end-of-life on July 31, 2026 — this migration is no longer optional maintenance, it's closing a real security gap.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;app/&lt;/code&gt; directory default is backwards-compatible and rarely the thing that actually breaks; the data layer and default-value changes are.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; calls sharing a key now enforce matching options — centralize shared calls into one composable to guarantee it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt; default to &lt;code&gt;undefined&lt;/code&gt; instead of &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;data&lt;/code&gt; is a &lt;code&gt;shallowRef&lt;/code&gt; — both fail silently, not loudly, so grep for them rather than waiting for a bug report.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;future.compatibilityVersion: 4&lt;/code&gt; lets you fix every breaking change while still running the Nuxt 3 package, which is the official, lowest-risk path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is mysterious once you have the list — it's a short, specific set of boundaries Nuxt now enforces instead of leaving implicit. Run the compatibility flag, work the list once, and the actual package bump becomes the smallest, most boring step in the whole migration.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/nuxt-weekly-4-migration-breaking-changes/playground" rel="noopener noreferrer"&gt;▶️ Open the interactive playground →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Runs right in your browser — poke at it and watch the concept react live.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 Test yourself
&lt;/h2&gt;

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/nuxt-weekly-4-migration-breaking-changes/quiz" rel="noopener noreferrer"&gt;Take the 8-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What's the first thing on this list you'd find if you grepped your own app for it right now?&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nuxt-hydration-mismatch-why-it-happens-and-how-to-fix-it-5b7i"&gt;Nuxt Hydration Mismatch: Why It Happens and How to Fix It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/useasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1"&gt;useAsyncData Keys in Nuxt: Caching, Dedupe &amp;amp; the Sharing Bug&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nuxt-45-ssr-streaming-the-route-rules-that-disable-it-1daj"&gt;Nuxt 4.5 SSR Streaming: The Route Rules That Disable It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🚀 &lt;strong&gt;Want more like this?&lt;/strong&gt; Every guide, playground, and quiz lives on &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;bestpractic.org&lt;/a&gt;&lt;/strong&gt; — open it and &lt;strong&gt;&lt;a href="https://bestpractic.org/" rel="noopener noreferrer"&gt;sign up free&lt;/a&gt;&lt;/strong&gt; so the next one finds you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks for reading! Let's stay connected:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub&lt;/strong&gt; — follow me and star the projects: &lt;a href="https://github.com/parsajiravand" rel="noopener noreferrer"&gt;github.com/parsajiravand&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;Discord&lt;/strong&gt; — join the frontend best-practices community: &lt;a href="https://discord.gg/d9KRhuAwQ" rel="noopener noreferrer"&gt;discord.gg/d9KRhuAwQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📸 &lt;strong&gt;Instagram&lt;/strong&gt; — frontend best practices, daily: &lt;a href="https://www.instagram.com/bestpractice___/" rel="noopener noreferrer"&gt;@bestpractice___&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nuxt</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
