<?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 J</title>
    <description>The latest articles on DEV Community by Parsa J (@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%2Fthepracticaldev.s3.amazonaws.com%2Fi%2F99mvlsfu5tfj9m7ku25d.png</url>
      <title>DEV Community: Parsa J</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>Stop Shipping Search Boxes With a Race Condition</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Sat, 07 Nov 2026 06:21:08 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-shipping-search-boxes-with-a-race-condition-169g</link>
      <guid>https://dev.to/parsajiravand/stop-shipping-search-boxes-with-a-race-condition-169g</guid>
      <description>&lt;p&gt;Type "react" into a search box, watch it fetch results, then quickly backspace and type "vue" instead. For half a second, the dropdown shows &lt;code&gt;react&lt;/code&gt; results under the word &lt;code&gt;vue&lt;/code&gt;. Then it corrects itself, or it doesn't, and either way nobody files a bug report because by the time you'd screenshot it, it's gone.&lt;/p&gt;

&lt;p&gt;This happened at a filter UI I worked on — product search, nothing exotic, one &lt;code&gt;fetch()&lt;/code&gt; call per keystroke. QA flagged it as "occasionally shows stale results, can't reproduce." That phrase should make you suspicious immediately. "Can't reproduce" usually means "reproduces under conditions I'm not controlling for" — and the condition here was network speed, which nobody controls on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that made it rarer, not gone
&lt;/h2&gt;

&lt;p&gt;The instinct, reasonably, was to stop firing so many requests:&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;timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;searchInput&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;input&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;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="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;runSearch&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;target&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="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runSearch&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="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;query&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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="nf"&gt;renderResults&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;Debounce the input, wait 250ms of silence before firing. Fewer requests, fewer chances to collide — the bug got rarer. It did not go away. On a slow connection, or with a server that happens to answer a short query faster than a long one, two debounced requests can still be in flight at once, and they can still finish in the wrong order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading — what do you think "wrong order" actually means here?&lt;/strong&gt; It's not that the requests go out of order. It's that they &lt;em&gt;come back&lt;/em&gt; out of order, and nothing in the code above checks which one it's looking at before painting it on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response you render isn't the one you think it is
&lt;/h2&gt;

&lt;p&gt;Here's the part that doesn't show up in a quick read of the code: &lt;code&gt;fetch()&lt;/code&gt; makes no promise about finish order. You can send request A, then request B a moment later, and B's response can arrive first — a shorter query, a warmer cache entry, a server that happens to be less loaded for that specific request. Nothing is broken. This is just what concurrent network calls do.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;renderResults(data)&lt;/code&gt; in the snippet above has no idea which query the data it just received belongs to. It just paints whatever shows up last. If A was &lt;code&gt;"react"&lt;/code&gt; and B was &lt;code&gt;"vue"&lt;/code&gt;, and A's response happens to land after B's, the box ends up showing react results while the input says &lt;code&gt;vue&lt;/code&gt;. The bug isn't in your fetch call. It's in the fact that you're treating "the last response that arrived" as if it were "the response to my last request" — and those are only the same thing when nothing is racing.&lt;/p&gt;

&lt;p&gt;That's the whole bug, named precisely: it's a &lt;strong&gt;race condition&lt;/strong&gt; — two async operations whose outcome depends on which one happens to finish first, and "happens to" is exactly the word that should worry you. It's not about when things run on the main thread. It's about the gap between &lt;em&gt;starting&lt;/em&gt; an operation and &lt;em&gt;acting on its result&lt;/em&gt;, and anything else that can also act in that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop trusting arrival order, track identity instead
&lt;/h2&gt;

&lt;p&gt;You can't force responses to come back in order — that's a property of the network, not your code. What you &lt;em&gt;can&lt;/em&gt; do is make sure that when a response arrives, you check whether it's still the one you care about before you act on it.&lt;/p&gt;

&lt;p&gt;The smallest version of that is a counter:&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;latestRequestId&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runSearch&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latestRequestId&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;query&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;latestRequestId&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="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a newer search started while this one was in flight — drop it&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;renderResults&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;Every call to &lt;code&gt;runSearch&lt;/code&gt; claims the next number and remembers it. When its response finally arrives — however late, however out of order — it checks whether it's still the most recent request anyone asked for. If a newer one started in the meantime, this one's result is thrown away, silently, no matter how long it took to get here. The one request that's still "latest" when its answer arrives is the only one allowed to touch the DOM.&lt;/p&gt;

&lt;p&gt;Three lines. No library, no state management rewrite. The requests still race each other exactly as before — you've just stopped letting the loser win.&lt;/p&gt;

&lt;p&gt;If you'd rather cancel the stale request outright instead of just ignoring its answer, &lt;code&gt;AbortController&lt;/code&gt; does that — &lt;code&gt;controller.abort()&lt;/code&gt; on the previous request before firing a new one, and a caught &lt;code&gt;AbortError&lt;/code&gt; instead of the manual counter check. That trims a network call that would otherwise complete for nothing, which is worth it on anything heavier than a search box. The counter guard above is the version to reach for when you don't control the request layer, or just want the smallest possible fix.&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/fetch-race-condition-stale-responses/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;
  
  
  It's not just search boxes
&lt;/h2&gt;

&lt;p&gt;Once you see this shape, you start noticing it everywhere two async calls can overlap and only one should win:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tabs in a dashboard&lt;/strong&gt; — click "Revenue," then quickly click "Users." If Revenue's data is bigger and arrives after Users', the Users tab briefly shows revenue numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infinite scroll with filters&lt;/strong&gt; — change a filter mid-scroll, and an in-flight page request for the &lt;em&gt;old&lt;/em&gt; filter lands after the new one does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autosave&lt;/strong&gt; — type, pause, type again, pause. Two save requests, and if the first one is slow, it can overwrite the second one's result on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimistic UI rollback&lt;/strong&gt; — a retry fired after a timeout can resolve after the original request you thought had failed, and now you've "rolled back" a success.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these has the same fix: attach an identity to the thing you're waiting for, and check it's still current before you act on the answer. React Query and SWR build this in for you automatically — stale responses are dropped before they ever reach your UI, which is a large part of why "just use a data-fetching library" is good advice, not laziness. Redux Toolkit's &lt;code&gt;createAsyncThunk&lt;/code&gt; doesn't drop them for you, but it hands you the exact piece the guard above needs: every dispatched thunk carries a unique &lt;code&gt;meta.requestId&lt;/code&gt;, so instead of inventing your own counter you compare that id in your reducer. But knowing the three-line version means you're never stuck when you're not using one of those, which is most code that isn't a component's data layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard the identity, not the order
&lt;/h2&gt;

&lt;p&gt;The lesson generalizes past fetch: whenever you start an async operation and plan to act on its result later, something else can start in the meantime and finish first. You can't make the network, the event loop, or another developer's code behave in request order for you. You can make sure that when a result shows up, you check whether it's still the one you're waiting for before you let it touch anything a user can see.&lt;/p&gt;

&lt;p&gt;Go find the oldest &lt;code&gt;fetch()&lt;/code&gt; or &lt;code&gt;setTimeout&lt;/code&gt; callback in your codebase that fires more than once per user action — a search box, a save button, a polling loop. Does anything check that the response it just got is still the one it wants? If the honest answer is no, you've found a race condition that just hasn't been unlucky yet.&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/fetch-race-condition-stale-responses/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>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>programming</category>
    </item>
    <item>
      <title>You're Blocking Scroll by Default. `passive: true` Fixes It.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Fri, 06 Nov 2026 06:10:54 +0000</pubDate>
      <link>https://dev.to/parsajiravand/youre-blocking-scroll-by-default-passive-true-fixes-it-24lf</link>
      <guid>https://dev.to/parsajiravand/youre-blocking-scroll-by-default-passive-true-fixes-it-24lf</guid>
      <description>&lt;p&gt;A teammate scrolled the product list on their phone and said it felt "sticky" — not broken, just a little behind, like the page was thinking about it before it moved. On a laptop trackpad it was perfectly smooth. Same code, same list, same 12-line scroll handler that did almost nothing: check &lt;code&gt;scrollY&lt;/code&gt;, toggle a "back to top" button.&lt;/p&gt;

&lt;p&gt;Twelve lines. Nothing in it that should cost more than a millisecond. And yet, on a phone, every scroll felt like it had a half-beat of drag.&lt;/p&gt;

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

&lt;p&gt;The obvious read is "the handler is too slow," so that's where the first hour went:&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;list&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;scroll&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;backToTop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&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="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&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;Wait — not even &lt;code&gt;scroll&lt;/code&gt;. The actual listener was on &lt;code&gt;touchmove&lt;/code&gt;, added so the button could react mid-gesture instead of waiting for the scroll to settle:&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;list&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;touchmove&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;backToTop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&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="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&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 line is the whole bug, and nothing in it looks dangerous. We debounced it. We memoized the scroll position. We moved the class toggle behind &lt;code&gt;requestAnimationFrame&lt;/code&gt;. Chrome DevTools' performance panel kept showing the same thing after every change: a yellow "Rendering" block sitting &lt;em&gt;before&lt;/em&gt; the scroll could start, not after the handler ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading — what do you think the browser was actually waiting for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It wasn't waiting for the handler to be fast. It was waiting to find out whether the handler would call &lt;code&gt;preventDefault()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a &lt;code&gt;touchmove&lt;/code&gt; listener can freeze a scroll that never gets cancelled
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;touchmove&lt;/code&gt;, &lt;code&gt;touchstart&lt;/code&gt;, and &lt;code&gt;wheel&lt;/code&gt; are special. Any listener for one of them is allowed to call &lt;code&gt;event.preventDefault()&lt;/code&gt; and cancel the browser's default action — in this case, cancel the scroll itself, mid-gesture. A custom image-zoom gesture, a swipe-to-dismiss card, a "no, don't scroll past this point" lock — all of those work by listening to one of these events and vetoing the browser's plan.&lt;/p&gt;

&lt;p&gt;The browser doesn't know in advance that &lt;em&gt;your&lt;/em&gt; handler isn't one of those. So by default it plays it safe: run the handler first, check &lt;code&gt;event.defaultPrevented&lt;/code&gt;, and only &lt;em&gt;then&lt;/em&gt; move the page. Your handler reads &lt;code&gt;scrollY&lt;/code&gt; and sets &lt;code&gt;.hidden&lt;/code&gt; — completely harmless — but the browser can't see that from the outside. It has to ask.&lt;/p&gt;

&lt;p&gt;That ask is synchronous, and it happens on the main thread. If the main thread is busy with literally anything else at that instant — a re-render, a timer, a layout recalculation — the scroll waits behind it. Debouncing the handler doesn't help, because the delay isn't the handler's &lt;em&gt;duration&lt;/em&gt;. It's the fact that scrolling is gated on the handler returning at all, every single time it fires.&lt;/p&gt;

&lt;p&gt;This is exactly what Lighthouse is flagging when it tells you a page "does not use passive listeners to improve scrolling performance." It isn't measuring your code's speed. It's measuring whether you've told the browser it's allowed to stop asking.&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/passive-event-listeners-scroll-performance/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 one-line fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;addEventListener&lt;/code&gt; takes a third argument beyond the usual boolean-for-capture — an options object with a &lt;code&gt;passive&lt;/code&gt; flag:&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;list&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;touchmove&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;backToTop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&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="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&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;passive&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;{ passive: true }&lt;/code&gt; is a promise: &lt;em&gt;this listener will never call &lt;code&gt;preventDefault()&lt;/code&gt;&lt;/em&gt;. The browser takes you at your word, stops waiting, and starts scrolling the instant the gesture begins — on the compositor thread, independent of whatever your handler is doing. The handler still runs, still takes however long it takes, but scrolling no longer holds its breath for the answer.&lt;/p&gt;

&lt;p&gt;Break that promise and call &lt;code&gt;preventDefault()&lt;/code&gt; inside a passive listener anyway, and nothing happens — the call is silently ignored. Chrome and Firefox log a console warning telling you so; Safari doesn't, so don't count on the console to catch the mistake for you. There's no partial credit. A listener is either allowed to cancel the gesture, or it isn't, and &lt;code&gt;passive&lt;/code&gt; is you choosing which.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try scrolling the list below with the toggle off, then on — same handler, same cost, very different feel.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  "Isn't this already the default?"
&lt;/h2&gt;

&lt;p&gt;Sort of, and that's the trap. A few browsers quietly mark &lt;em&gt;some&lt;/em&gt; touch listeners passive on their own — but only in specific cases (certain root-level targets, specific event types), and the exact cases differ by engine and have changed over time as each one shipped its own optimization. Relying on an undocumented default that varies by browser and by which element you attached to is not a strategy. It's a bet.&lt;/p&gt;

&lt;p&gt;The one-line fix works everywhere, on every engine, whether or not that engine happens to be feeling generous today:&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;list&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;touchmove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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="nx"&gt;list&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;wheel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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;Add it to every &lt;code&gt;touchstart&lt;/code&gt;, &lt;code&gt;touchmove&lt;/code&gt;, or &lt;code&gt;wheel&lt;/code&gt; listener where you genuinely never call &lt;code&gt;preventDefault()&lt;/code&gt; — scroll-position trackers, infinite-scroll triggers, analytics pings, "show the nav on scroll up" toggles, if you've wired them to one of those three events. That's most of them. (A plain &lt;code&gt;scroll&lt;/code&gt; listener doesn't need this at all — &lt;code&gt;scroll&lt;/code&gt; events are never cancelable, so the browser was never waiting on them in the first place.) The only time you &lt;em&gt;can't&lt;/em&gt; use &lt;code&gt;{ passive: true }&lt;/code&gt; is when the listener sometimes needs to cancel the gesture — a custom swipe handler, a drag-to-reorder list, a pinch-zoom lock. In that case, leaving it non-passive is correct, not an oversight — you're keeping the veto because you actually use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list, scrolling again
&lt;/h2&gt;

&lt;p&gt;Back-to-top button, 12 lines, one options object added. The teammate who first noticed the stutter scrolled the same list on the same phone and didn't say anything — which, for a performance fix, is exactly the review you want. Nobody writes a bug report about something that just works.&lt;/p&gt;

&lt;p&gt;Go check one scroll handler in your own codebase right now — does it call &lt;code&gt;preventDefault()&lt;/code&gt; anywhere? If the honest answer is no, it's missing one argument. What's the oldest scroll listener in your code that's never had &lt;code&gt;{ passive: true }&lt;/code&gt; added — and do you know whether it blocks anything?&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/passive-event-listeners-scroll-performance/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/stop-guessing-when-scrolling-stops-use-scrollend-42m7"&gt;Stop Guessing When Scrolling Stops — Use scrollend&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/i-built-cross-tab-logout-the-storage-event-skipped-one-tab-3ja2"&gt;I Built Cross-Tab Logout. The storage Event Skipped One Tab.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/you-added-will-change-to-fix-the-jank-you-made-it-worse-51eg"&gt;You Added &lt;code&gt;will-change&lt;/code&gt; to Fix the Jank. You Made It Worse.&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>performance</category>
      <category>browser</category>
    </item>
    <item>
      <title>NestJS 12.2: Why validationError.target Is Now undefined</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Fri, 06 Nov 2026 06:10:24 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nestjs-122-why-validationerrortarget-is-now-undefined-53n6</link>
      <guid>https://dev.to/parsajiravand/nestjs-122-why-validationerrortarget-is-now-undefined-53n6</guid>
      <description>&lt;p&gt;A NestJS app upgrades from &lt;code&gt;@nestjs/core&lt;/code&gt; 12.1 to 12.2 — &lt;code&gt;npm i @nestjs/core@latest @nestjs/common@latest&lt;/code&gt;, nothing else touched. The test suite stays green. In production, an audit log that has recorded every failed signup for months — the field that failed, the message, and the full payload the user actually submitted — starts writing &lt;code&gt;submittedPayload: undefined&lt;/code&gt; on every single entry. No exception, no crash, no warning in the logs. The validation itself still works exactly as before: bad input still gets rejected with a &lt;code&gt;400&lt;/code&gt;. Only the forensic trail disappeared.&lt;/p&gt;

&lt;p&gt;This is written against &lt;strong&gt;&lt;code&gt;@nestjs/core&lt;/code&gt; 12.2.0&lt;/strong&gt; (verified 2026-10-20 via &lt;code&gt;npm view @nestjs/core dist-tags&lt;/code&gt; and the &lt;a href="https://github.com/nestjs/nest/releases/tag/v12.2.0" rel="noopener noreferrer"&gt;official v12.2.0 release notes&lt;/a&gt;; &lt;code&gt;12.0.0&lt;/code&gt; shipped 2026-08-27, and the &lt;code&gt;11.x&lt;/code&gt; line remains on the &lt;code&gt;legacy&lt;/code&gt; npm tag at &lt;code&gt;11.2.7&lt;/code&gt;). The change described here — &lt;code&gt;validationError.target&lt;/code&gt; defaulting to &lt;code&gt;false&lt;/code&gt; — is listed by name in that release's "new defaults and behaviour changes" section, not inferred from a diff. It applies to the class-validator-based &lt;code&gt;ValidationPipe&lt;/code&gt; only; it does not touch the newer &lt;code&gt;StandardSchemaValidationPipe&lt;/code&gt; covered in an &lt;a href="https://dev.to/parsajiravand/nestjs-12-standard-schema-validation-without-class-validator-161f"&gt;earlier episode of this series&lt;/a&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 exactly what &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt;, and &lt;code&gt;property&lt;/code&gt; each mean on a class-validator &lt;code&gt;ValidationError&lt;/code&gt;, and which of them NestJS's own built-in &lt;code&gt;exceptionFactory&lt;/code&gt; actually uses&lt;/li&gt;
&lt;li&gt;State precisely why &lt;code&gt;@nestjs/core&lt;/code&gt; 12.2 changed &lt;code&gt;validationError.target&lt;/code&gt;'s default to &lt;code&gt;false&lt;/code&gt;, in the maintainers' own words&lt;/li&gt;
&lt;li&gt;Fix a custom &lt;code&gt;exceptionFactory&lt;/code&gt; that silently breaks on the upgrade, without just disabling the new default&lt;/li&gt;
&lt;li&gt;Restore the pre-12.2 behavior deliberately, globally or per pipe, when you genuinely need it&lt;/li&gt;
&lt;li&gt;Place this change correctly on the request lifecycle's pipes stage, and rule out the two other places it could be confused for a bug&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You've registered a &lt;code&gt;ValidationPipe&lt;/code&gt; — global or scoped — against &lt;code&gt;class-validator&lt;/code&gt; DTOs at least once, and ideally you've written or read a custom &lt;code&gt;exceptionFactory&lt;/code&gt;. If you haven't read this series' &lt;a href="https://dev.to/parsajiravand/nestjs-request-lifecycle-explained-with-cheat-sheet-227l"&gt;NestJS request lifecycle&lt;/a&gt; episode, it maps exactly where a pipe runs relative to guards, interceptors, and filters — useful background, but this article stands on its own.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: an exceptionFactory that used to work&lt;/li&gt;
&lt;li&gt;The mental model: target, value, and property are three different things&lt;/li&gt;
&lt;li&gt;Stage 1: why 12.2 changed the default&lt;/li&gt;
&lt;li&gt;Stage 2: fixing the broken exceptionFactory&lt;/li&gt;
&lt;li&gt;Stage 3: restoring the old behavior on purpose&lt;/li&gt;
&lt;li&gt;Stage 4: the quieter 12.2 changes riding along&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: an exceptionFactory that used to work
&lt;/h2&gt;

&lt;p&gt;Here's the pipe registration behind the audit log in the intro, unchanged across the upgrade:&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;// main.ts&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalPipes&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;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;exceptionFactory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;errors&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;auditLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt; &lt;span class="o"&gt;??&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="c1"&gt;// this line is what broke — quietly&lt;/span&gt;
        &lt;span class="na"&gt;submittedPayload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;Before 12.2, &lt;code&gt;first.target&lt;/code&gt; was the whole object &lt;code&gt;class-validator&lt;/code&gt; had just validated — the full DTO instance built from the request body, every field included, whether it passed or failed. That made it an easy, if slightly lazy, way to log "here's exactly what the user sent" next to the one field that failed. After 12.2, with nothing else in the application touched, &lt;code&gt;first.target&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. &lt;code&gt;submittedPayload: undefined&lt;/code&gt; is syntactically valid, so nothing throws — the audit log entry is just worse than it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: target, value, and property are three different things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; a class-validator &lt;code&gt;ValidationError&lt;/code&gt; is a small record for &lt;strong&gt;one failing property&lt;/strong&gt;, and it can carry up to four separate pieces of information about that failure — and only one of them changed in 12.2:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;property&lt;/code&gt;&lt;/strong&gt; — the name of the field that failed, e.g. &lt;code&gt;"email"&lt;/code&gt;. Always present. Unaffected by this change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;constraints&lt;/code&gt;&lt;/strong&gt; — a map of rule name to human-readable message, e.g. &lt;code&gt;{ isEmail: "email must be an email" }&lt;/code&gt;. Always present. Unaffected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;value&lt;/code&gt;&lt;/strong&gt; — the actual value that was submitted &lt;em&gt;for that one field&lt;/em&gt;, e.g. &lt;code&gt;"not-an-email"&lt;/code&gt;. This is scoped to the failing property alone. &lt;strong&gt;Unaffected by the 12.2 change.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;target&lt;/code&gt;&lt;/strong&gt; — not the field's value, but &lt;strong&gt;the entire object being validated&lt;/strong&gt;, with every property, whether it passed or failed. This is the one &lt;code&gt;@nestjs/core&lt;/code&gt; 12.2 now suppresses by default.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That distinction is the whole article: &lt;code&gt;value&lt;/code&gt; tells you what was wrong with the one field that failed; &lt;code&gt;target&lt;/code&gt; repeats the complete request body on every single error in the array. A DTO with three failing fields used to produce three &lt;code&gt;ValidationError&lt;/code&gt; objects, each one carrying a full copy of the same object. NestJS's own source comment on the change states the reasoning plainly: &lt;em&gt;"the target of an error is the whole object it was found in, so an exception factory that sends the errors would repeat that object."&lt;/em&gt; Repeating it isn't just noisy — it's also how a request body with a password field, a token, or any other sensitive value ends up duplicated into every validation error a naive &lt;code&gt;exceptionFactory&lt;/code&gt; forwards to a client or a log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: why 12.2 changed the default
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;@nestjs/core&lt;/code&gt; 12.2, &lt;code&gt;ValidationPipe&lt;/code&gt; didn't configure &lt;code&gt;validationError&lt;/code&gt; for you at all — it passed your &lt;code&gt;validatorOptions&lt;/code&gt; straight through to class-validator's &lt;code&gt;validate()&lt;/code&gt;, and class-validator's own long-standing default includes &lt;code&gt;target&lt;/code&gt;. Since 12.2, &lt;code&gt;ValidationPipe.transform()&lt;/code&gt; builds that option itself, and your own setting — if you have one — wins:&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;// @nestjs/common's ValidationPipe, from 12.2.0 onward (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errors&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;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;validatorOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;validationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the new default&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;validatorOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;validationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// your setting, if any, overrides it&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; this is a default &lt;em&gt;you can always override&lt;/em&gt;, not a removed feature. If you never touch &lt;code&gt;validationError&lt;/code&gt;, you now get &lt;code&gt;target: false&lt;/code&gt;. If you explicitly configure &lt;code&gt;validationError: { target: true }&lt;/code&gt; on your &lt;code&gt;ValidationPipe&lt;/code&gt;, that object sits on top of the spread above and wins, on every version from 12.2 onward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: fixing the broken exceptionFactory
&lt;/h2&gt;

&lt;p&gt;The honest fix usually isn't restoring &lt;code&gt;target&lt;/code&gt; — it's noticing that &lt;code&gt;target&lt;/code&gt; was the wrong tool for the job. The audit log wanted "what did the user submit," and that's exactly what &lt;code&gt;value&lt;/code&gt; already gives you, scoped to the field that actually failed, with no risk of dragging unrelated fields (passwords, tokens) along for the ride:&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalPipes&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;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;exceptionFactory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;errors&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;auditLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt; &lt;span class="o"&gt;??&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;submittedValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&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="c1"&gt;// scoped to this field — works on every 12.x&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;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;value&lt;/code&gt; was never touched by the 12.2 change, on any version. If your &lt;code&gt;exceptionFactory&lt;/code&gt; only needs to know what the &lt;em&gt;failing field&lt;/em&gt; contained, switching from &lt;code&gt;target&lt;/code&gt; to &lt;code&gt;value&lt;/code&gt; fixes the bug today and keeps working on whatever the default does next.&lt;/p&gt;

&lt;p&gt;If you genuinely need the whole object — reconstructing the full attempted request for a support ticket, say — read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: restoring the old behavior on purpose
&lt;/h2&gt;

&lt;p&gt;When the full object really is the requirement, say so explicitly instead of relying on an implicit default that already changed once:&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalPipes&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;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;validationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&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;// explicit — survives future default changes&lt;/span&gt;
    &lt;span class="na"&gt;exceptionFactory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;errors&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;auditLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt; &lt;span class="o"&gt;??&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;submittedPayload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// present again, on purpose&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;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;&lt;strong&gt;Key concept:&lt;/strong&gt; this is scoped to wherever you construct that particular &lt;code&gt;ValidationPipe&lt;/code&gt;. A global pipe with &lt;code&gt;validationError: { target: true }&lt;/code&gt; restores the old behavior app-wide; a route-scoped &lt;code&gt;@UsePipes(new ValidationPipe({ validationError: { target: true } }))&lt;/code&gt; restores it only there, leaving the global pipe on the new, leaner default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: the quieter 12.2 changes riding along
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;target&lt;/code&gt; isn't the only &lt;code&gt;ValidationPipe&lt;/code&gt; default the same release changed. Two more are worth knowing before you assume a &lt;code&gt;400&lt;/code&gt; you're suddenly seeing is a bug in your code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// New in 12.2 — caps how long the combined message string can get.&lt;/span&gt;
  &lt;span class="c1"&gt;// Past this, extra messages are replaced with one "omitted" entry.&lt;/span&gt;
  &lt;span class="na"&gt;maxErrorMessagesLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// this is already the default&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: sending an &lt;strong&gt;array or a bare primitive&lt;/strong&gt; where a validated class is expected now gets a &lt;code&gt;400&lt;/code&gt; instead of silently passing an unvalidated value through. If a route's DTO is &lt;code&gt;CreateUserDto&lt;/code&gt; and the body arrives as &lt;code&gt;[1, 2, 3]&lt;/code&gt; or the string &lt;code&gt;"oops"&lt;/code&gt;, 12.2 rejects it at the pipe instead of letting it reach the handler shaped nothing like the DTO. Both changes ship in the same release and share the same spirit as the &lt;code&gt;target&lt;/code&gt; default: stop assuming more than class-validator was actually asked to check.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The built-in &lt;code&gt;exceptionFactory&lt;/code&gt; was never affected.&lt;/strong&gt; &lt;code&gt;@nestjs/common&lt;/code&gt;'s own default error formatter — the one &lt;code&gt;ValidationPipe&lt;/code&gt; uses when you don't pass your own — only ever reads &lt;code&gt;property&lt;/code&gt; and &lt;code&gt;constraints&lt;/code&gt; to build its message array. If you've never written a custom &lt;code&gt;exceptionFactory&lt;/code&gt;, this entire change is invisible to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nested DTOs still carry &lt;code&gt;children&lt;/code&gt;, not a nested &lt;code&gt;target&lt;/code&gt;.&lt;/strong&gt; A failure on &lt;code&gt;address.zip&lt;/code&gt; produces a &lt;code&gt;ValidationError&lt;/code&gt; with &lt;code&gt;children: [{ property: 'address', children: [{ property: 'zip', ... }] }]&lt;/code&gt;; &lt;code&gt;target&lt;/code&gt; suppression applies at every level of that tree the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is specific to class-validator's &lt;code&gt;ValidationPipe&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;StandardSchemaValidationPipe&lt;/code&gt; (covered in &lt;a href="https://dev.to/parsajiravand/nestjs-12-standard-schema-validation-without-class-validator-161f"&gt;an earlier episode&lt;/a&gt;) validates with a Standard Schema library's own &lt;code&gt;~standard.validate()&lt;/code&gt;, which has no &lt;code&gt;target&lt;/code&gt; concept at all — nothing here applies to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pipe constructed before you set the option doesn't retroactively pick it up.&lt;/strong&gt; &lt;code&gt;validationError&lt;/code&gt; is read once, in the constructor's resolved options — set it when you call &lt;code&gt;new ValidationPipe({ ... })&lt;/code&gt;, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging &lt;code&gt;error.target&lt;/code&gt; to the console for local debugging is still fine.&lt;/strong&gt; The change doesn't remove the data from the validation run itself — it only removes it from what reaches &lt;code&gt;exceptionFactory&lt;/code&gt; by default. &lt;code&gt;console.log&lt;/code&gt; statements inside your own services that build the object from the raw request body are unaffected either way.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default to &lt;code&gt;error.value&lt;/code&gt;, not &lt;code&gt;error.target&lt;/code&gt;, in any &lt;code&gt;exceptionFactory&lt;/code&gt; you write.&lt;/strong&gt; It's scoped to the one field that failed, was never affected by this change, and can't accidentally leak an unrelated sensitive field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never echo the full submitted payload back to the client in an error response&lt;/strong&gt;, with or without this default — a validation error is exactly the response an attacker can use to fingerprint which fields your DTO expects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;validationError: { target: true }&lt;/code&gt; explicitly, and only, where you've confirmed you need it&lt;/strong&gt; — an internal audit log or a support tool, never a response body a client sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit every custom &lt;code&gt;exceptionFactory&lt;/code&gt; in the codebase before upgrading to 12.2&lt;/strong&gt;, the same way you'd check for any other breaking default: &lt;code&gt;grep -rn "exceptionFactory" src/&lt;/code&gt; and look for &lt;code&gt;.target&lt;/code&gt; on the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the behavior you rely on, not the default.&lt;/strong&gt; The &lt;code&gt;target: true&lt;/code&gt; default existed for years and still changed once; writing it explicitly means the next default change can't surprise you again.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Does this break validation itself, or just error reporting?
&lt;/h3&gt;

&lt;p&gt;Just error reporting. Validation still runs exactly the same way, and the HTTP response code and the fact that invalid requests get rejected are unchanged. Only what a &lt;em&gt;custom&lt;/em&gt; &lt;code&gt;exceptionFactory&lt;/code&gt; can read off each error changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will my app silently stop validating anything after this upgrade?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;target&lt;/code&gt; being &lt;code&gt;undefined&lt;/code&gt; has nothing to do with whether validation ran — it only affects what one specific field on the resulting error object contains.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;error.value&lt;/code&gt; ever get suppressed the same way?
&lt;/h3&gt;

&lt;p&gt;No. The 12.2 release notes and the pipe's own source only change the &lt;code&gt;target&lt;/code&gt; default; &lt;code&gt;value&lt;/code&gt; — the specific field's submitted value — is untouched on every 12.x version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this in the official migration guide, or just the release notes?
&lt;/h3&gt;

&lt;p&gt;It's listed in the &lt;a href="https://github.com/nestjs/nest/releases/tag/v12.2.0" rel="noopener noreferrer"&gt;v12.2.0 release notes&lt;/a&gt; under that release's own "new defaults and behaviour changes," since it's a minor-version default change rather than a major-version breaking change requiring a migration guide entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I set &lt;code&gt;validationError.target&lt;/code&gt; per route instead of globally?
&lt;/h3&gt;

&lt;p&gt;Yes — pass the option to a &lt;code&gt;ValidationPipe&lt;/code&gt; instance scoped with &lt;code&gt;@UsePipes()&lt;/code&gt; on a controller or a single handler, the same way any other &lt;code&gt;ValidationPipe&lt;/code&gt; option can be scoped.&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;Task&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Check if your code is affected&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep -rn "exceptionFactory" src/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only custom factories reading &lt;code&gt;.target&lt;/code&gt; are affected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix forward (recommended)&lt;/td&gt;
&lt;td&gt;Read &lt;code&gt;error.value&lt;/code&gt; instead of &lt;code&gt;error.target&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Scoped to the failing field; unaffected by this change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore old behavior, globally&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new ValidationPipe({ validationError: { target: true } })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Register once, in &lt;code&gt;main.ts&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore old behavior, one route&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@UsePipes(new ValidationPipe({ validationError: { target: true } }))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Leaves the global pipe on the new default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirm the built-in factory is unaffected&lt;/td&gt;
&lt;td&gt;Don't pass &lt;code&gt;exceptionFactory&lt;/code&gt; at all&lt;/td&gt;
&lt;td&gt;The default formatter only ever used &lt;code&gt;property&lt;/code&gt; + &lt;code&gt;constraints&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;target&lt;/code&gt; vs &lt;code&gt;value&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;target&lt;/code&gt; = whole submitted object · &lt;code&gt;value&lt;/code&gt; = one field's value&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;target&lt;/code&gt; changed its default in 12.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other 12.2 &lt;code&gt;ValidationPipe&lt;/code&gt; defaults&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;maxErrorMessagesLength&lt;/code&gt; (new), array/primitive → &lt;code&gt;400&lt;/code&gt; (new)&lt;/td&gt;
&lt;td&gt;Same release, same "stop assuming more than was checked" theme&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main.ts — explicit, upgrade-proof configuration&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalPipes&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;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="c1"&gt;// Only set this if something genuinely needs the whole submitted object.&lt;/span&gt;
    &lt;span class="c1"&gt;// validationError: { target: true },&lt;/span&gt;
    &lt;span class="na"&gt;exceptionFactory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;errors&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt; &lt;span class="o"&gt;??&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;submittedValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&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="c1"&gt;// not first.target&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;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@nestjs/core&lt;/code&gt; 12.2 defaults &lt;code&gt;validationError.target&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; on &lt;code&gt;ValidationPipe&lt;/code&gt; — a deliberate change, documented in the v12.2.0 release notes, not a regression.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;target&lt;/code&gt; is the &lt;strong&gt;entire validated object&lt;/strong&gt;; &lt;code&gt;value&lt;/code&gt; is the &lt;strong&gt;one failing field's value&lt;/strong&gt;. Only &lt;code&gt;target&lt;/code&gt;'s default changed; &lt;code&gt;value&lt;/code&gt; never moves.&lt;/li&gt;
&lt;li&gt;NestJS's own built-in &lt;code&gt;exceptionFactory&lt;/code&gt; never read &lt;code&gt;target&lt;/code&gt; — this only bites a hand-written one, which is exactly why it fails silently instead of at boot.&lt;/li&gt;
&lt;li&gt;Fix forward by reading &lt;code&gt;error.value&lt;/code&gt;, or restore the old behavior deliberately with &lt;code&gt;validationError: { target: true }&lt;/code&gt;, globally or per route.&lt;/li&gt;
&lt;li&gt;This lives entirely in the pipes stage of the request lifecycle — guards, interceptors, and filters are untouched by it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Ending
&lt;/h2&gt;

&lt;p&gt;The bug in the intro was never really about NestJS removing something — it was about a default quietly doing less work on an app's behalf than it used to, in a way that compiles, ships, and only shows up as a slightly worse log entry months later. That's the same shape of risk every implicit default carries: it's free until the day it changes, and the fix is almost always the same one — write down what you actually need (&lt;code&gt;error.value&lt;/code&gt;, not "whatever &lt;code&gt;target&lt;/code&gt; happens to contain") instead of leaning on whatever the framework currently hands you.&lt;/p&gt;

&lt;p&gt;Has a NestJS minor-version upgrade ever changed behavior you didn't know you were depending on? 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/nestjs-weekly-validation-error-target/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/nestjs-weekly-validation-error-target/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;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nestjs-12-standard-schema-validation-without-class-validator-161f"&gt;NestJS 12: Standard Schema Validation Without class-validator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nestjs-guards-canactivate-executioncontext-reflector-1onm"&gt;NestJS Guards: CanActivate, ExecutionContext &amp;amp; Reflector&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nestjs-request-lifecycle-explained-with-cheat-sheet-227l"&gt;NestJS Request Lifecycle Explained (with 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>nestjs</category>
      <category>typescript</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop Hacking Base64 With btoa(). toBase64() Fixes It Natively.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Thu, 05 Nov 2026 06:10:25 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-hacking-base64-with-btoa-tobase64-fixes-it-natively-2kkm</link>
      <guid>https://dev.to/parsajiravand/stop-hacking-base64-with-btoa-tobase64-fixes-it-natively-2kkm</guid>
      <description>&lt;p&gt;The feature was simple: let someone export a report and share it as a single link, PDF baked right in as a data URL. Worked first try. Worked in the demo. Worked for two weeks in production.&lt;/p&gt;

&lt;p&gt;Then someone exported a 40-page report, and the share button threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RangeError: Maximum call stack size exceeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a network error. Not a permissions error. The encoding step itself — the part of the code nobody had touched since the first version — blew the call stack on a PDF that was, by file standards, perfectly ordinary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code that worked right up until it didn't
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shareReport&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="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="nx"&gt;url&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;bytes&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;Uint8Array&lt;/span&gt;&lt;span class="p"&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;arrayBuffer&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;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`data:application/pdf;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base64&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;This is the pattern you'll find in half the Stack Overflow answers for "convert ArrayBuffer to base64 JavaScript." It reads fine. It passed code review. It's also a time bomb, and the fuse length depends on file size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading: what do you think &lt;code&gt;...bytes&lt;/code&gt; is actually doing to &lt;code&gt;String.fromCharCode&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's not passing an array. &lt;code&gt;String.fromCharCode&lt;/code&gt; doesn't take one — it takes a list of arguments, one per character code. The spread operator expands every single byte into its own argument in that function call. A 2KB test file is 2,000-ish arguments. Fine. A 40-page PDF is tens of thousands of bytes, which means tens of thousands of arguments in one call — and every JS engine caps how many arguments a function call can take. Cross that line and you don't get a graceful error, you get a stack blow-up with a message that tells you nothing about where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  It gets worse with text, not just files
&lt;/h2&gt;

&lt;p&gt;Swap the binary file for something simpler — base64-encoding a string for a shareable URL — and &lt;code&gt;btoa()&lt;/code&gt; has a &lt;em&gt;second&lt;/em&gt; failure mode:&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="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello 👋 World&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// DOMException: Failed to execute 'btoa' on 'Window':&lt;/span&gt;
&lt;span class="c1"&gt;// The string to be encoded contains characters outside of&lt;/span&gt;
&lt;span class="c1"&gt;// the Latin1 range.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;btoa()&lt;/code&gt; was never built to handle arbitrary text. Its actual contract is a "byte string" — every character has to map to a single byte, U+0000 through U+00FF. That range covers plenty of accented Latin letters (&lt;code&gt;café&lt;/code&gt; and &lt;code&gt;José&lt;/code&gt; actually encode fine, just not as UTF-8), but step outside it — an emoji, an em dash, a name with a character like &lt;code&gt;Ł&lt;/code&gt; or a Cyrillic letter in it — and it's an instant throw.&lt;/p&gt;

&lt;p&gt;The classic fix you'll find for this one is uglier than the bug:&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="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;unescape&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;text&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, in the sense that it doesn't throw. It also routes your string through &lt;code&gt;unescape()&lt;/code&gt; — a function the spec deprecated decades ago and keeps around purely so this exact hack doesn't break the web. If you've ever wondered why base64-encoding a string in JavaScript feels like three functions wearing a trenchcoat, this is why: &lt;code&gt;btoa()&lt;/code&gt; does one narrow job, and everything else is a workaround bolted on from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why both bugs have the same root cause
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;btoa()&lt;/code&gt; and &lt;code&gt;atob()&lt;/code&gt; operate on strings, not bytes. Any time your actual data is binary — a fetched file, a crypto digest, Unicode text — you have to convert it into a byte-string-shaped JavaScript string first. That conversion step is where both bugs live: &lt;code&gt;String.fromCharCode(...bytes)&lt;/code&gt; is how you build that string for binary data, and it's the one that blows the call stack. &lt;code&gt;encodeURIComponent&lt;/code&gt; plus &lt;code&gt;unescape&lt;/code&gt; is how you build it for Unicode text, and it's the one that's a deprecated-function sandwich.&lt;/p&gt;

&lt;p&gt;The actual problem was never base64. It was the round trip through a string that base64 never needed in the first place.&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/uint8array-tobase64/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 fix: encode the bytes directly
&lt;/h2&gt;

&lt;p&gt;As of late 2025, &lt;code&gt;Uint8Array&lt;/code&gt; has two methods that skip the string entirely:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shareReport&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="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="nx"&gt;url&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;bytes&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;Uint8Array&lt;/span&gt;&lt;span class="p"&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;arrayBuffer&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;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// no spread, no stack limit&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`data:application/pdf;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base64&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;One call. No &lt;code&gt;String.fromCharCode&lt;/code&gt;, no spread, no argument-count ceiling, because nothing is being passed as individual arguments — &lt;code&gt;toBase64()&lt;/code&gt; walks the bytes internally and hands back a string. Decoding mirrors 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the text case, pair it with &lt;code&gt;TextEncoder&lt;/code&gt;/&lt;code&gt;TextDecoder&lt;/code&gt; instead of the &lt;code&gt;unescape&lt;/code&gt; hack:&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;base64&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;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello 👋 World&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBase64&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;text&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// text === "Hello 👋 World"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TextEncoder&lt;/code&gt; turns the string into UTF-8 bytes correctly — emoji, accents, anything — and &lt;code&gt;toBase64()&lt;/code&gt; takes it from there. No deprecated functions, no silent mis-encoding.&lt;/p&gt;

&lt;h3&gt;
  
  
  The option you'll actually reach for: URL-safe tokens
&lt;/h3&gt;

&lt;p&gt;Standard base64 uses &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;, both of which need escaping in a URL or a filename. &lt;code&gt;toBase64()&lt;/code&gt; takes an &lt;code&gt;alphabet&lt;/code&gt; option so you don't hand-roll the replacement:&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&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;Uint8Array&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;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;alphabet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;omitPadding&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;// "Kx8pQ2v9mZt3dF7h..."  — safe to drop straight into a URL, no `+`, `/`, or `=`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;omitPadding: true&lt;/code&gt; strips the trailing &lt;code&gt;=&lt;/code&gt; characters too — the part that trips up anyone who's ever copy-pasted a base64 token into a query string and watched it get mangled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth being honest about
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Uint8Array.prototype.toBase64()&lt;/code&gt; and &lt;code&gt;Uint8Array.fromBase64()&lt;/code&gt; reached Baseline "newly available" in September 2025 — Chrome 140, Firefox 133, Safari 18.2, and the versions after. That's all three engines, not a single-vendor experiment, which is why it's worth adopting now rather than waiting. It is &lt;em&gt;not&lt;/em&gt; in anything older than that, though, so if your app still supports last year's browsers, feature-detect before you rely on it:&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;hasNativeBase64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toBase64&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep your &lt;code&gt;btoa&lt;/code&gt;/&lt;code&gt;atob&lt;/code&gt; path behind that check for a while, rather than ripping it out the day you read this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The report export, fixed
&lt;/h2&gt;

&lt;p&gt;The 40-page PDF that used to crash the share button now encodes in one line, with no upper bound tied to how many arguments a function call can hold. The emoji that used to throw a &lt;code&gt;DOMException&lt;/code&gt; round-trips cleanly through &lt;code&gt;TextEncoder&lt;/code&gt;. Neither fix required a library — both were already shipping in the runtime, waiting for the hand-rolled version to get retired.&lt;/p&gt;

&lt;p&gt;If you've still got a &lt;code&gt;String.fromCharCode(...bytes)&lt;/code&gt; or an &lt;code&gt;unescape(encodeURIComponent(...))&lt;/code&gt; somewhere in your codebase, it's probably not broken yet. It's just waiting for a big enough file or a weird enough character. What's the oldest base64 hack still running in your production code — do you know what size file would finally break it?&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/uint8array-tobase64/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>javascript</category>
      <category>webdev</category>
      <category>browser</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Running the meeting is a different job than being in one</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Wed, 04 Nov 2026 06:10:55 +0000</pubDate>
      <link>https://dev.to/parsajiravand/running-the-meeting-is-a-different-job-than-being-in-one-12f5</link>
      <guid>https://dev.to/parsajiravand/running-the-meeting-is-a-different-job-than-being-in-one-12f5</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 your second week owning the Tuesday planning sync, and forty minutes in, you notice the same two people have already agreed on the answer — you can see it in how they're not really asking each other questions anymore, just confirming — and everyone else is still taking turns giving their opinion like it's a vote that hasn't happened yet. You called this meeting. You're supposed to be running it. And you have absolutely no idea what to do with what you just noticed.&lt;/p&gt;

&lt;p&gt;That's the actual job, and nobody tells you it's the job, because the invite just says "owner."&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not the same skill as talking in one
&lt;/h2&gt;

&lt;p&gt;You can be the person who speaks up well in a meeting — lands a point, asks the sharp question at the right moment — and still have no idea how to run one, because they're not the same muscle. Speaking in a meeting is about contributing to a conversation that's already happening. Running one is about deciding when the conversation is actually over, which is a much weirder thing to have to call.&lt;/p&gt;

&lt;p&gt;Most meetings get decided before they start. Not maliciously — just structurally. Two people talk in a thread, or in the hallway, or on a call that wasn't this one, and by the time the room convenes, there's already an answer; the meeting itself is where that answer gets performed back as if it's being discovered live. Everyone plays along, partly out of politeness and partly because nobody wants to be the one who says "didn't we already decide this."&lt;/p&gt;

&lt;p&gt;Your job, once you're the one running it, is to notice that moment and do something about it instead of letting the room keep performing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second audience nobody mentioned
&lt;/h2&gt;

&lt;p&gt;There's a newer wrinkle on top of the old one. A lot of these meetings now have something else in the room besides people — a note-taker that's actually a model, quietly producing a transcript and a summary afterward. More people are skipping the meeting entirely and reading that summary instead, trusting it to carry the parts that matter. Which means the thing you're running isn't just for the people in the call anymore. It's also, increasingly, for whoever reads the write-up tomorrow and never heard the tone, the hesitation, the "well, I guess" that told you someone wasn't actually on board.&lt;/p&gt;

&lt;p&gt;That changes what's worth saying out loud. A decision that only lives in the room's collective memory doesn't survive contact with a transcript built by something that can't hear hesitation. If it matters, it has to get said plainly enough to survive being summarized by a machine that wasn't paying attention to anything except words.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what do you actually say?
&lt;/h2&gt;

&lt;p&gt;Three moves, and all three are just different ways of naming the gap between what's being performed and what's actually true.&lt;/p&gt;

&lt;p&gt;When you notice the room has converged and is still going through the motions: &lt;strong&gt;"I think we've converged — can I read that back as the decision?"&lt;/strong&gt; Say the decision in one sentence. If nobody corrects it, the meeting is over, whatever's left on the agenda. If someone does correct it, good — you just found out it wasn't actually settled, which is useful information you didn't have thirty seconds ago.&lt;/p&gt;

&lt;p&gt;When you suspect the question isn't really live anymore, and you want to check before you cut it off: &lt;strong&gt;"What would change your mind right now?"&lt;/strong&gt; If the answer is a real thing — a number, a person's opinion, a test result — it's still open, keep going. If the answer is a shrug or a restatement of the same position, it was decided before you walked in, and you can say so without it being a jab at anyone.&lt;/p&gt;

&lt;p&gt;When there's a side conversation happening in the room that's really a two-person problem wearing a group meeting as a costume: &lt;strong&gt;"Let's take that offline, the two of you, and bring back a recommendation."&lt;/strong&gt; This does two things at once — it stops the rest of the room from sitting through a negotiation that isn't theirs, and it gives the two people involved somewhere to actually finish the thing instead of circling it in front of an audience.&lt;/p&gt;

&lt;p&gt;And for the transcript problem specifically: &lt;strong&gt;"I'm going to say the decision out loud so it's actually in the notes, not just in our heads."&lt;/strong&gt; It sounds almost silly to announce, but it's the sentence that turns a vague paragraph in tomorrow's AI summary into an actual decision someone can point back to in a month.&lt;/p&gt;

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

&lt;p&gt;Naming "I think we've converged" works when you have some standing to call it — when you're the one who convened the meeting, or you're a peer to the room. It does not work the same way when your manager, or your manager's manager, is in that room and the thing you'd be naming is that &lt;em&gt;they've&lt;/em&gt; already decided something and are letting everyone else perform catching up to it. Say that sentence in that room and you're not running the meeting anymore, you're correcting someone with more leverage than you, in front of witnesses, and that's a different and much riskier act than the same sentence said to a group of peers. If you're junior and the decision-makers outrank you, the honest move is usually to ask the "what would change your mind" question privately, afterward, not to call the room out in it.&lt;/p&gt;

&lt;p&gt;The transcript sentence has its own failure mode too — it can read as performing for the record rather than talking to the people in front of you, especially if you say it every single time. Use it when the decision is actually the kind that gets argued about later, not as a tic.&lt;/p&gt;

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

&lt;p&gt;The person who ends up running these meetings is very often not the most senior person in the room — it's whoever's most organized, or whoever volunteered once and it stuck. That's worth saying plainly, because running a meeting well functions as leadership — you're the one deciding what counts as decided — but it doesn't always get recognized or compensated as leadership, especially if you came into the role sideways instead of through a title. You can do this job extremely well for a long time and have it read as "good at logistics" rather than "good at judgment," which is the same skill wearing a less flattering name.&lt;/p&gt;

&lt;p&gt;And there's a quieter politics now too, with the note-taker in the room: who gets to see the raw transcript, whether it gets cleaned up before it's shared, whether someone's half-finished sentence becomes part of the permanent record of what they think. Nobody puts that in the meeting invite, but if you're the one running it, you're also, a little bit, the one deciding what gets to count as having been said.&lt;/p&gt;

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

&lt;p&gt;Before your next recurring meeting, write down — just for yourself, two minutes, before anyone joins — which items on the agenda are actually open questions and which ones you already suspect are decided. Then say that split out loud at the top: "I think the first two are real decisions, the third one I suspect is already settled — tell me if I'm wrong." You're not performing authority. You're just telling the room what kind of meeting it's about to be, instead of making everyone find out the slow way.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/someone-else-got-the-promotion-and-youre-being-told-to-wait-2ej1"&gt;Someone else got the promotion, and you're being told to wait&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/when-saying-no-feels-like-volunteering-to-be-next-381d"&gt;When saying no feels like volunteering to be next&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/junior-interviews-dont-test-what-they-used-to-and-nobody-updated-you-1h01"&gt;Junior interviews don't test what they used to, and nobody updated you&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;

&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>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>getCurrentPosition() Has No Default Timeout. That's the Trap.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Wed, 04 Nov 2026 06:10:24 +0000</pubDate>
      <link>https://dev.to/parsajiravand/getcurrentposition-has-no-default-timeout-thats-the-trap-3h5p</link>
      <guid>https://dev.to/parsajiravand/getcurrentposition-has-no-default-timeout-thats-the-trap-3h5p</guid>
      <description>&lt;p&gt;The ticket read: "location button sometimes just spins forever." Not a crash, not an error toast — just a spinner that never stopped. QA couldn't reproduce it. It worked every time on the office Wi-Fi, every time on a laptop, every time in the demo to the client.&lt;/p&gt;

&lt;p&gt;It only happened out in the field. On a phone. Somewhere the GPS fix took its time and the Wi-Fi-based positioning fallback was slow to answer. Which meant it was exactly the one environment nobody tested in before shipping.&lt;/p&gt;

&lt;p&gt;Here's the part that made it worse: the code had an error handler. Both arguments to &lt;code&gt;getCurrentPosition()&lt;/code&gt; were filled in, success and failure. By every code-review instinct, this was handled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code that looks fully handled
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMyLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFail&lt;/span&gt;&lt;span class="p"&gt;)&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;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&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;onFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;onFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A success callback, an error callback, nothing swallowed. If you squint, it even looks defensive — most &lt;code&gt;getCurrentPosition()&lt;/code&gt; snippets you'll find in the wild look exactly like this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading: what has to happen for that error callback to fire at all?&lt;/strong&gt; Not "what does it do" — when does the browser decide to call it?&lt;/p&gt;

&lt;p&gt;The honest answer is: only when the browser gives up and reports a &lt;code&gt;GeolocationPositionError&lt;/code&gt; — permission denied, position unavailable, or a timeout. And a timeout is an &lt;em&gt;option you pass in&lt;/em&gt;. If you don't pass one, there's nothing to time out. The browser just... keeps trying. Forever, if it has to. Neither callback is under any obligation to ever run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default timeout is not "some reasonable number." It's &lt;code&gt;Infinity&lt;/code&gt;.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getCurrentPosition()&lt;/code&gt; takes a third, optional &lt;code&gt;options&lt;/code&gt; argument: &lt;code&gt;{ enableHighAccuracy, timeout, maximumAge }&lt;/code&gt;. Skip it — like the snippet above does — and you get the spec's own defaults:&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;enableHighAccuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maximumAge&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timeout: Infinity&lt;/code&gt; means precisely what it says: the call will wait as long as the device needs to produce a position, with no upper bound. On a laptop with recent Wi-Fi positioning data cached nearby, that's near-instant, so the bug never shows up in testing. On a phone with a weak signal indoors, "as long as it needs" can be minutes — or, if the underlying location provider never calls back at all, forever. There's no silent fallback, no eventual error. The spinner you built to show &lt;em&gt;while waiting&lt;/em&gt; just keeps being accurate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other default nobody reads: &lt;code&gt;maximumAge: 0&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;maximumAge&lt;/code&gt; controls whether the browser is allowed to hand back a cached position instead of asking the device for a brand-new fix. The default is &lt;code&gt;0&lt;/code&gt; milliseconds — meaning no cache is old enough to use, ever. Every single call to &lt;code&gt;getCurrentPosition()&lt;/code&gt;, even one fired a hundred milliseconds after the last one, forces a fresh GPS/Wi-Fi/cell fix from scratch.&lt;/p&gt;

&lt;p&gt;That's rarely what you actually want. A "locate me" button a user double-clicks, or a component that calls &lt;code&gt;getCurrentPosition()&lt;/code&gt; on mount right after a sibling component just did the same thing, don't need two independent fixes a tenth of a second apart. Passing a real &lt;code&gt;maximumAge&lt;/code&gt; — "a position up to 30 seconds old is fine" — turns repeat calls into near-free cache hits instead of two full location requests:&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;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maximumAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;
  
  
  &lt;code&gt;watchPosition()&lt;/code&gt; has the same shape of bug as a forgotten &lt;code&gt;clearInterval&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getCurrentPosition()&lt;/code&gt; asks once. &lt;code&gt;watchPosition()&lt;/code&gt; asks continuously, calling your success callback every time the device's position changes meaningfully — useful for live tracking, useless the moment nobody's watching the screen anymore. It returns a watch ID, and that ID is your responsibility:&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;watchId&lt;/span&gt; &lt;span class="o"&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;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watchPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;enableHighAccuracy&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// later, when you're done:&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;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearWatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;watchId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip that last line — the same way it's easy to skip &lt;code&gt;clearInterval()&lt;/code&gt; — and the watch keeps running after the component that started it is gone. In a React component, that's a &lt;code&gt;watchPosition()&lt;/code&gt; call in a &lt;code&gt;useEffect&lt;/code&gt; with no cleanup function: the GPS radio stays active, the callback keeps firing into a component that no longer exists, and the battery drain shows up in a support ticket that has nothing to do with location on its face.&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="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;id&lt;/span&gt; &lt;span class="o"&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;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watchPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onError&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearWatch&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="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;One line in the cleanup function. Easy to write, easy to forget, and nothing in the API warns you that you forgot it.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not a Promise, and that's the last gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getCurrentPosition()&lt;/code&gt; predates &lt;code&gt;fetch()&lt;/code&gt;, predates &lt;code&gt;Promise&lt;/code&gt;, and was never updated to support either — it's success/error callbacks, full stop. You can't &lt;code&gt;await&lt;/code&gt; it directly. The fix is a wrapper you write once and reuse everywhere:&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;function&lt;/span&gt; &lt;span class="nf"&gt;getPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&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="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="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geolocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;enableHighAccuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;maximumAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;pos&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;getPosition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;useLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&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;err&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PERMISSION_DENIED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showPermissionHelp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showRetry&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;showGenericError&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 default that bit the original snippet is now an explicit choice, and the whole thing reads top to bottom instead of nesting two callbacks inside each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one real constraint: it needs a secure origin
&lt;/h2&gt;

&lt;p&gt;This part isn't a trap, just worth stating plainly: every major browser requires a secure context — HTTPS, or &lt;code&gt;localhost&lt;/code&gt; in development — before it will even ask for location permission. That's been true for close to a decade now, not a new restriction, and there's no workaround or flag to reach for; if a &lt;code&gt;getCurrentPosition()&lt;/code&gt; call is silently failing in a way none of the above explains, check the protocol in the address bar first.&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/getcurrentposition-not-a-promise/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;p&gt;The playground above simulates the GPS fix with a slider, so you get the exact same race every time instead of waiting on whatever signal your actual device has right now. Set a slow "fix," turn on "use the default options," and watch the stopwatch climb with nothing to stop it — that's &lt;code&gt;timeout: Infinity&lt;/code&gt;, reproduced on demand. Then try the &lt;code&gt;watchPosition()&lt;/code&gt; panel: start it, hit "simulate component unmount" without stopping it first, and watch the counter keep climbing anyway.&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/getcurrentposition-not-a-promise/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 fix for that "spins forever" ticket was three lines: a &lt;code&gt;timeout&lt;/code&gt;, a &lt;code&gt;maximumAge&lt;/code&gt;, and a &lt;code&gt;clearWatch()&lt;/code&gt; in a cleanup function that had never been written. None of it was hard once someone knew to look for it — the hard part was realizing that "sometimes it just hangs" wasn't a flaky bug report. It was the spec's own default, read literally.&lt;/p&gt;

&lt;p&gt;Does your codebase have a bare &lt;code&gt;getCurrentPosition()&lt;/code&gt; call anywhere without a &lt;code&gt;timeout&lt;/code&gt;? Worth a five-minute grep before it becomes someone else's "impossible to reproduce" ticket.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/stop-losing-promise-errors-unhandledrejection-catches-them-2ckf"&gt;Stop Losing Promise Errors. &lt;code&gt;unhandledrejection&lt;/code&gt; Catches Them.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/my-catch-never-ran-the-bug-was-two-lines-above-it-3nil"&gt;My .catch() Never Ran. The Bug Was Two Lines Above It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/javascript-proxy-and-reflect-the-complete-guide-4ehf"&gt;JavaScript Proxy and Reflect: The Complete Guide&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>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Canvas Is Freezing the Page. OffscreenCanvas Fixes That.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Tue, 03 Nov 2026 06:10:56 +0000</pubDate>
      <link>https://dev.to/parsajiravand/your-canvas-is-freezing-the-page-offscreencanvas-fixes-that-hn9</link>
      <guid>https://dev.to/parsajiravand/your-canvas-is-freezing-the-page-offscreencanvas-fixes-that-hn9</guid>
      <description>&lt;p&gt;You open the profiler, record a session, and the flame chart looks great. 60 frames a second, steady, no dropped frames. You ship it.&lt;/p&gt;

&lt;p&gt;Then someone clicks a button while the animation is running, and for half a second, nothing happens. Not a dropped frame — the whole page. The click just sits there, queued, waiting for a thread that's busy painting pixels nobody asked it to interrupt.&lt;/p&gt;

&lt;p&gt;Here's the part that doesn't show up in a frame-rate graph: your canvas animation was never competing with itself. It was competing with every click handler, every layout pass, every bit of input handling on the page — because by default, all of that runs on exactly one thread, and canvas drawing calls are first-class citizens on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone reaches for first
&lt;/h2&gt;

&lt;p&gt;The instinct is to blame the math. Heavy canvas work — thousands of particles, a live chart redrawing every tick, a game loop doing collision checks — looks like a computation problem, so people reach for a Web Worker to do the computation off-thread.&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;// main.js&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;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;particles-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="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;tick&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;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt; &lt;span class="o"&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;positions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...now draw them, back on the main thread&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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;positions&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="nf"&gt;fillRect&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;x&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;y&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="mi"&gt;2&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 genuinely helps — the position math moves off the main thread. But watch what's still happening in that &lt;code&gt;onmessage&lt;/code&gt; handler: &lt;code&gt;clearRect&lt;/code&gt;, &lt;code&gt;fillRect&lt;/code&gt;, every actual drawing call, is still running on the main thread, because that's the only thread with access to the &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element's rendering context. You moved the arithmetic. You didn't move the part that was actually fighting your clicks for time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that's easy to miss: drawing itself blocks the page
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element's 2D or WebGL context has always been tied to the DOM, and anything tied to the DOM only exists on the main thread. That's not a performance detail, it's a structural one: a worker has no &lt;code&gt;document&lt;/code&gt;, so it has never been able to hold a canvas context at all — until there was a way to give it one that isn't attached to the page.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;OffscreenCanvas&lt;/code&gt; is. Not a faster canvas. A canvas whose rendering context can live somewhere other than the main thread, so the fastest possible canvas is one where the drawing calls stop queuing up behind your &lt;code&gt;click&lt;/code&gt; listeners in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: move the context itself into the worker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;canvas&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"scene"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"400"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/canvas&amp;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;// main.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scene&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;offscreen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transferControlToOffscreen&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;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;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;render-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="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offscreen&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// the main thread still owns input — forward what the worker needs&lt;/span&gt;
&lt;span class="nx"&gt;canvas&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;pointermove&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;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;worker&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;pointer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&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;offsetX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&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;offsetY&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="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// render-worker.js&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ctx&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;pointer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&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;y&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="nb"&gt;self&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;canvas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&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;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loop&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;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;pointer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;pointer&lt;/span&gt; &lt;span class="o"&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;()&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="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&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;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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="nf"&gt;beginPath&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="nf"&gt;arc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loop&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;code&gt;transferControlToOffscreen()&lt;/code&gt; detaches the canvas element from its own rendering context and hands you an &lt;code&gt;OffscreenCanvas&lt;/code&gt; you can transfer to a worker like any other transferable object. Inside the worker, &lt;code&gt;getContext("2d")&lt;/code&gt; gives back something that draws almost exactly like the context you're used to — same &lt;code&gt;fillRect&lt;/code&gt;, same &lt;code&gt;beginPath&lt;/code&gt;, same &lt;code&gt;drawImage&lt;/code&gt; — except every one of those calls now runs on a thread that has nothing else to do but draw.&lt;/p&gt;

&lt;p&gt;The one thing that doesn't come along for free is input. A worker has no DOM, so it can't attach its own &lt;code&gt;pointermove&lt;/code&gt; or &lt;code&gt;click&lt;/code&gt; listeners to anything. The main thread stays the one place that owns events, and your job is to forward the few fields the worker actually needs — as shown above, just &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt;, not the whole event object, which isn't transferable anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the page stays responsive now
&lt;/h2&gt;

&lt;p&gt;Open two tabs in your head. In the first, with everything on the main thread, a click has to wait in the same queue as every pending &lt;code&gt;fillRect&lt;/code&gt; call — if the canvas is mid-frame, the click waits for the frame to finish. In the second, with &lt;code&gt;OffscreenCanvas&lt;/code&gt;, the canvas's frame is happening on a different thread entirely. The main thread's event queue is short again, because the one thing that used to dominate it moved out.&lt;/p&gt;

&lt;p&gt;This is the distinction worth sitting with: OffscreenCanvas doesn't make your particle system compute faster, and it doesn't raise your frame rate. It makes the main thread's queue shorter by taking an entire category of work out of it. A canvas that was drawing 10,000 particles at 60fps before will still be drawing 10,000 particles at roughly 60fps after — the difference is that a click, a keypress, or a scroll now gets handled in the same frame instead of after it.&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/offscreencanvas-canvas-off-main-thread/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;
  
  
  When the main thread was fine all along
&lt;/h2&gt;

&lt;p&gt;Not every canvas needs this. A sparkline that redraws once when new data arrives, a static chart, an icon you draw once on mount — none of that is holding the main thread hostage, because none of it is running every frame. The tell isn't "I use canvas," it's "my canvas work and my users' clicks are both trying to happen &lt;em&gt;right now&lt;/em&gt;, repeatedly, for as long as the page is open." Games, live data visualizations, drawing tools, anything with a &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop that runs indefinitely — that's the category where this earns its complexity.&lt;/p&gt;

&lt;p&gt;It's also not free complexity. You lose direct DOM access inside the worker, so anything that reads layout, measures text against real fonts in edge cases, or needs synchronous access to other page state has to be designed around message passing from the start, not bolted on later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this safe to ship today?
&lt;/h2&gt;

&lt;p&gt;Yes. Chrome has supported &lt;code&gt;OffscreenCanvas&lt;/code&gt; since 2018, Firefox enabled it for workers a few years after that, and Safari was the last to land it, in Safari 16.4 in March 2023. All three major engines have carried it for a while now, so there's no meaningful fallback story you need to write — check &lt;code&gt;"OffscreenCanvas" in window&lt;/code&gt; if you want a belt-and-suspenders guard, but you're unlikely to need it.&lt;/p&gt;

&lt;p&gt;The next time a profiler shows you a perfectly smooth 60fps canvas sitting on top of a page that still stutters when someone clicks, you'll know the frame rate was never the number that mattered.&lt;/p&gt;

&lt;p&gt;What's the heaviest thing you're currently drawing to a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; on the main thread? I'm curious how many of you are one &lt;code&gt;transferControlToOffscreen()&lt;/code&gt; call away from fixing a bug you've been blaming on something else.&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/offscreencanvas-canvas-off-main-thread/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-ui-is-freezing-because-you-never-gave-the-browser-a-break-2p80"&gt;Your UI is freezing because you never gave the browser a break&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/localstorage-isnt-free-its-blocking-your-main-thread-nmn"&gt;localStorage Isn't Free — It's Blocking Your Main Thread&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/your-tabs-each-open-their-own-websocket-navigatorlocks-elects-one-24gh"&gt;Your Tabs Each Open Their Own WebSocket. navigator.locks Elects One.&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>performance</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Next.js 16.4 ensureStatic: Locking Routes Fully Static</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Tue, 03 Nov 2026 06:10:25 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nextjs-164-ensurestatic-locking-routes-fully-static-30dp</link>
      <guid>https://dev.to/parsajiravand/nextjs-164-ensurestatic-locking-routes-fully-static-30dp</guid>
      <description>&lt;p&gt;You ship a route you're sure is fully static. A teammate adds one &lt;code&gt;fetch()&lt;/code&gt; call to a shared layout three weeks later — no cache directive, just a quick lookup they needed for a banner. Nothing errors. The build succeeds. The route still "looks" static in dev. It isn't anymore: that one call now forces the whole subtree to render on every request, and nobody notices until the latency dashboard does.&lt;/p&gt;

&lt;p&gt;This is written against &lt;strong&gt;Next.js 16.4.3&lt;/strong&gt; (verified against the framework's own GitHub release notes and documentation as of October 2026). The guardrail for exactly this scenario — &lt;code&gt;ensureStatic&lt;/code&gt; — was stabilized in &lt;strong&gt;16.4.0&lt;/strong&gt;, after shipping unstable for a few releases. This series mentioned it in passing two weeks ago; this episode is the first real look at what it actually checks, and what it refuses to let you ship.&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;ensureStatic&lt;/code&gt; actually guarantees, and why "the page renders fast in dev" isn't the same guarantee&lt;/li&gt;
&lt;li&gt;Pick the right level — &lt;code&gt;'shell'&lt;/code&gt;, &lt;code&gt;'prefetch'&lt;/code&gt;, or &lt;code&gt;'navigation'&lt;/code&gt; — for a given route&lt;/li&gt;
&lt;li&gt;Predict exactly which build failures &lt;code&gt;ensureStatic = 'navigation'&lt;/code&gt; will produce, before you hit them&lt;/li&gt;
&lt;li&gt;Apply the inheritance rules correctly across layouts, nested segments, and parallel routes&lt;/li&gt;
&lt;li&gt;Tell &lt;code&gt;ensureStatic&lt;/code&gt; apart from &lt;code&gt;instant&lt;/code&gt;, a different, dev-time-only check with a similar name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; you're comfortable with the App Router and Server Components, and you've enabled Cache Components (&lt;code&gt;cacheComponents: true&lt;/code&gt;) or are about to. If &lt;code&gt;"use cache"&lt;/code&gt;, &lt;code&gt;cacheLife()&lt;/code&gt;, and the App Shell are new to you, this series' &lt;a href="https://dev.to/parsajiravand/nextjs-cache-components-explained-with-cheat-sheet-55ob"&gt;Cache Components Explained&lt;/a&gt; and &lt;a href="https://dev.to/parsajiravand/nextjs-163-instant-navigations-how-partial-prefetching-works-5a6h"&gt;Instant Navigations: How Partial Prefetching Works&lt;/a&gt; cover the pieces &lt;code&gt;ensureStatic&lt;/code&gt; builds on — worth reading first, not required before this one makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The problem: "static" with no guarantee&lt;/li&gt;
&lt;li&gt;The mental model: four levels, one escalating promise&lt;/li&gt;
&lt;li&gt;Staged walkthrough: shell, then prefetch, then navigation&lt;/li&gt;
&lt;li&gt;What &lt;code&gt;'navigation'&lt;/code&gt; actually validates&lt;/li&gt;
&lt;li&gt;Edge cases and inheritance rules&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ensureStatic&lt;/code&gt; vs &lt;code&gt;instant&lt;/code&gt;&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: "static" with no guarantee
&lt;/h2&gt;

&lt;p&gt;Say you run a documentation site. Every page lives under a shared layout — header, sidebar nav, footer — and the page content itself is cached:&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;// app/docs/layout.tsx — looks static, isn't guaranteed to stay that way&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DocsLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"docs-shell"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Sidebar&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&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;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/docs/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDoc&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findBySlug&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DocPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="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="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&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;getDoc&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Article&lt;/span&gt; &lt;span class="na"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="si"&gt;}&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 is genuinely fast today. Nothing in the visible code is wrong. But nothing here &lt;em&gt;asserts&lt;/em&gt; that it stays fast. Three weeks later someone adds a personalized "you're on the free plan" banner to &lt;code&gt;&amp;lt;Header /&amp;gt;&lt;/code&gt;, reading a cookie with a plain &lt;code&gt;fetch&lt;/code&gt; to an account API — no &lt;code&gt;"use cache"&lt;/code&gt;, because it's "just a header, it's small." That one read makes the header request-scoped, which makes the layout request-scoped, which pulls every page under &lt;code&gt;app/docs/&lt;/code&gt; back to rendering per request. The build still succeeds. Nothing in the PR review catches it, because the diff is three lines in a component nobody thinks of as "the caching layer."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ensureStatic&lt;/code&gt; exists so that instead of discovering this in a latency graph, you discover it as a build failure, pointing at the exact file and the exact API call that broke the guarantee — the moment it's introduced, not the moment someone notices the site got slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: four levels, one escalating promise
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ensureStatic&lt;/code&gt; is a route segment config — exported from a &lt;code&gt;page.tsx&lt;/code&gt; or &lt;code&gt;layout.tsx&lt;/code&gt;, the same place you'd put &lt;code&gt;dynamic&lt;/code&gt; or &lt;code&gt;revalidate&lt;/code&gt; in the Pages Router-era App Router code. It requires Cache Components (&lt;code&gt;cacheComponents: true&lt;/code&gt;) to be enabled; without it, exporting &lt;code&gt;ensureStatic&lt;/code&gt; fails the build outright rather than doing nothing.&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;EnsureStatic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&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="s1"&gt;shell&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="s1"&gt;prefetch&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="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each value names a different &lt;em&gt;amount of the route's navigation lifecycle&lt;/em&gt; that must be static — and that lifecycle is the same one last episode's App Shell and Partial Prefetching work defines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A default &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;&lt;/strong&gt; (no &lt;code&gt;prefetch&lt;/code&gt; prop) loads the &lt;strong&gt;App Shell&lt;/strong&gt; — the parts of the route that don't depend on URL data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;Link prefetch={true}&amp;gt;&lt;/code&gt;&lt;/strong&gt; also resolves that specific link's URL-dependent data ahead of the click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The actual navigation&lt;/strong&gt; renders whatever is left after someone clicks.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;What must be static&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;'auto'&lt;/code&gt; (default — same as omitting the export)&lt;/td&gt;
&lt;td&gt;Nothing extra required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'shell'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The App Shell loaded by a plain &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'prefetch'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The App Shell &lt;strong&gt;and&lt;/strong&gt; every per-link prefetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'navigation'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The complete rendered route, including what streams in after the click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No requirement — and it blocks child segments from requiring one either&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read it as an escalating promise: &lt;code&gt;'shell'&lt;/code&gt; is the cheapest to satisfy (it only constrains what's shared across every route under that layout), &lt;code&gt;'navigation'&lt;/code&gt; is the strongest (it constrains the entire page, every time, for everyone). &lt;code&gt;'shell'&lt;/code&gt; and &lt;code&gt;'prefetch'&lt;/code&gt; only mean anything when Partial Prefetching is on — without it there's no separate shell stage to pin down, so &lt;code&gt;'shell'&lt;/code&gt; quietly does nothing and logs a warning. &lt;code&gt;'navigation'&lt;/code&gt; is different: it only needs Cache Components, not Partial Prefetching, because it's asserting something about the full render, not about a separate shell stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Staged walkthrough: shell, then prefetch, then navigation
&lt;/h2&gt;

&lt;p&gt;Take a small storefront: a cart layout shared by everything, product pages, and static buying guides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — lock the shell.&lt;/strong&gt; The cart layout reads a cookie to show an item count — personalized, so it can't be part of the shell. Wrap it in &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; with a static fallback, then assert the rest of the layout stays shell-eligible:&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;// app/(store)/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ensureStatic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StoreLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Logo&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CartBadge&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CartCount&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* reads a cookie — fine, it's inside Suspense */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;If anyone later moves cookie-reading code above that &lt;code&gt;Suspense&lt;/code&gt; boundary — directly into &lt;code&gt;StoreLayout&lt;/code&gt; — the build fails immediately, naming the offending file. That's the whole point: the mistake from the earlier docs example gets caught here instead of in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — lock prefetch.&lt;/strong&gt; Product pages are cached with &lt;code&gt;"use cache"&lt;/code&gt;, but the "in your cart" badge on each product card is personalized. &lt;code&gt;ensureStatic = 'prefetch'&lt;/code&gt; on the product list route asserts every one of those per-link prefetches resolves to static output — so hovering over twenty product cards doesn't fire twenty personalized round-trips:&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;// app/(store)/products/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ensureStatic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prefetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProducts&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;use cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&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;Step 3 — lock full navigation.&lt;/strong&gt; Buying guides are plain marketing content — no personalization, no request-time data, ever. That's the one case worth the strongest guarantee:&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;// app/(store)/guides/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ensureStatic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&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;guides&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAllSlugs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// must enumerate every slug&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;guides&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;g&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="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;g&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getGuide&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findBySlug&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the whole page — not just a shared layout slice — must render to static output, for every one of the slugs &lt;code&gt;generateStaticParams&lt;/code&gt; returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;'navigation'&lt;/code&gt; actually validates
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;'navigation'&lt;/code&gt; is the level worth understanding precisely, because it's the one that turns real mistakes into real build errors instead of silent regressions. With &lt;code&gt;cacheComponents&lt;/code&gt; on, exporting &lt;code&gt;ensureStatic = 'navigation'&lt;/code&gt; from a page makes the build (and the dev server) reject:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uncached data&lt;/strong&gt; — a bare &lt;code&gt;fetch()&lt;/code&gt; or database call with no &lt;code&gt;"use cache"&lt;/code&gt; wrapping it, and &lt;code&gt;connection()&lt;/code&gt; (the explicit "this must be dynamic" escape hatch)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime-only APIs&lt;/strong&gt; — &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, or &lt;code&gt;searchParams&lt;/code&gt; read on the server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache entries that are effectively dynamic anyway&lt;/strong&gt; — &lt;code&gt;"use cache"&lt;/code&gt; with an &lt;code&gt;expire&lt;/code&gt; under five minutes, a &lt;code&gt;stale&lt;/code&gt; under 30 seconds, or &lt;code&gt;revalidate: 0&lt;/code&gt;; a cache that invalidates that fast isn't meaningfully static&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request-dependent promises passed into a Client Component&lt;/strong&gt; — even if the server code itself looks cached, handing a promise whose value depends on the current request across the server/client boundary reintroduces a per-request dependency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dynamic segments must also export a &lt;code&gt;generateStaticParams()&lt;/code&gt; that returns the complete set of params — a partial list isn't enough once you've promised the whole route is static. The same constraints apply to &lt;code&gt;generateMetadata&lt;/code&gt; and &lt;code&gt;generateViewport&lt;/code&gt;, since metadata generated from a live &lt;code&gt;cookies()&lt;/code&gt; read would just as quietly break the promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and inheritance rules
&lt;/h2&gt;

&lt;p&gt;A few rules exist specifically to stop you from creating a guarantee that only holds in one place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A child segment can tighten freely, but can't explicitly downgrade.&lt;/strong&gt; A layout exporting &lt;code&gt;'shell'&lt;/code&gt; can have a page under it export &lt;code&gt;'navigation'&lt;/code&gt; (stronger) — that builds fine. Exporting &lt;code&gt;'auto'&lt;/code&gt; on that same page also builds fine: &lt;code&gt;'auto'&lt;/code&gt; is always treated as "no opinion," so it never conflicts with an ancestor's value — the layout's &lt;code&gt;'shell'&lt;/code&gt; requirement just keeps applying to the page underneath it. What actually fails the build is a child exporting a &lt;em&gt;weaker concrete level&lt;/em&gt; than an ancestor already set, e.g. a page exporting &lt;code&gt;'shell'&lt;/code&gt; under a layout that requires &lt;code&gt;'navigation'&lt;/code&gt; — that's a real contradiction, not a deferral, and Next.js rejects it by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;false&lt;/code&gt; and a level don't mix in the same tree.&lt;/strong&gt; If a parent is &lt;code&gt;false&lt;/code&gt; and a child tries to set &lt;code&gt;'shell'&lt;/code&gt;, the build fails — &lt;code&gt;false&lt;/code&gt; isn't "no opinion," it's an explicit "nothing here is required to be static," and a child can't retroactively impose a requirement its parent already waived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel routes rendered together must agree.&lt;/strong&gt; If two parallel slots render side by side, they must all share the same &lt;code&gt;ensureStatic&lt;/code&gt; value, leave it unset, or use &lt;code&gt;'auto'&lt;/code&gt; — one slot can't be &lt;code&gt;'navigation'&lt;/code&gt; while its sibling is &lt;code&gt;'shell'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A value applies to the whole route it's exported from, parent layouts included&lt;/strong&gt; — exporting it from a deep page doesn't exempt the layouts above it from the same requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;ensureStatic&lt;/code&gt; vs &lt;code&gt;instant&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;They sound similar and ship in the same release, which invites confusing them. They check different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ensureStatic&lt;/code&gt;&lt;/strong&gt; is a build-time (and dev-time) &lt;strong&gt;enforcement&lt;/strong&gt;. Export it, and the build fails if the promise doesn't hold. It's a contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;instant&lt;/code&gt;&lt;/strong&gt; is a &lt;strong&gt;dev-time signal&lt;/strong&gt;, not an enforced contract — it's a way to see, during development, whether a given navigation would paint instantly under the current code, without failing your build if it wouldn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;code&gt;instant&lt;/code&gt; while you're exploring which routes are candidates for a stronger guarantee. Use &lt;code&gt;ensureStatic&lt;/code&gt; once you've decided one of them needs that guarantee enforced going forward, not just observed today.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start at &lt;code&gt;'shell'&lt;/code&gt; on your root and section layouts&lt;/strong&gt;, not &lt;code&gt;'navigation'&lt;/code&gt; on every page — the shell is shared infrastructure, and locking it first catches the highest-leverage mistakes (a stray cookie read in a header) for the least ongoing cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserve &lt;code&gt;'navigation'&lt;/code&gt; for routes that are genuinely public and non-personalized&lt;/strong&gt; — marketing pages, docs, buying guides. Forcing it onto a route that legitimately needs request-time data just means fighting the build instead of fixing the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add it when a route is already correct, not to force it correct.&lt;/strong&gt; &lt;code&gt;ensureStatic&lt;/code&gt; is a regression guard, not a refactoring tool — get the caching right first with &lt;code&gt;"use cache"&lt;/code&gt; and &lt;code&gt;cacheLife()&lt;/code&gt;, confirm it behaves, then lock it in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a new &lt;code&gt;ensureStatic&lt;/code&gt; build failure as a real signal, not an obstacle.&lt;/strong&gt; It is almost always telling you that something just became request-scoped that your team didn't intend to make request-scoped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't apply &lt;code&gt;'prefetch'&lt;/code&gt; to a route with dozens of simultaneously-visible links&lt;/strong&gt; unless the per-link data is genuinely cheap and cached — it's an opt-in cost, not a free upgrade from &lt;code&gt;'shell'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;ensureStatic&lt;/code&gt; work without Cache Components?&lt;/strong&gt;&lt;br&gt;
No. It requires &lt;code&gt;cacheComponents: true&lt;/code&gt;; exporting it without that flag fails the build rather than silently doing nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I export &lt;code&gt;ensureStatic&lt;/code&gt; from a Client Component?&lt;/strong&gt;&lt;br&gt;
No — doing so throws an error. It's a server-side route segment config, like &lt;code&gt;dynamic&lt;/code&gt; or &lt;code&gt;revalidate&lt;/code&gt; before it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;'shell'&lt;/code&gt; do anything if I haven't enabled Partial Prefetching?&lt;/strong&gt;&lt;br&gt;
No. &lt;code&gt;'shell'&lt;/code&gt; and &lt;code&gt;'prefetch'&lt;/code&gt; only have teeth once Partial Prefetching is on — without it, Next.js logs a warning and the export has no effect. &lt;code&gt;'navigation'&lt;/code&gt; doesn't have this dependency; it works from Cache Components alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I only want to guard one slow subtree, not the whole route?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;ensureStatic&lt;/code&gt; is a whole-route (or whole-layout) assertion. For a single subtree, the docs point to deferring it explicitly with &lt;code&gt;await prefetch()&lt;/code&gt; (skip it during link prefetch) or &lt;code&gt;await navigation()&lt;/code&gt; (skip it until the click) instead of reaching for a route-level export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this the same as the old &lt;code&gt;export const dynamic = 'force-static'&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
No, and this is the important distinction. &lt;code&gt;force-static&lt;/code&gt; (and &lt;code&gt;dynamic&lt;/code&gt; generally) sets the rendering mode for the &lt;em&gt;entire&lt;/em&gt; route as one unit, pre-Cache-Components. &lt;code&gt;ensureStatic&lt;/code&gt; is built for a world where one route can legitimately mix prerendered, cached, and request-time content — it asserts a static guarantee over a specific &lt;em&gt;slice&lt;/em&gt; (shell, prefetch, or full navigation) of a route that's otherwise allowed to be mixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will I hit this if I'm still on Next.js 15?&lt;/strong&gt;&lt;br&gt;
No — &lt;code&gt;ensureStatic&lt;/code&gt; is a 16.4+ API, and it depends on Cache Components, which itself isn't the default until a later major. On 15.x, write around it rather than approximating it with &lt;code&gt;force-static&lt;/code&gt;, which doesn't carry the same per-slice guarantee.&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-ensurestatic-locks-static-output/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;
  
  
  Cheat sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ensureStatic values (route segment config, requires cacheComponents: true):

  'auto'        — default, same as omitting the export. No extra requirement.
  'shell'       — the App Shell (needs Partial Prefetching; else: warning, no-op)
  'prefetch'    — App Shell + every per-link prefetch (needs Partial Prefetching)
  'navigation'  — the full rendered route (Cache Components alone is enough)
  false         — no requirement; blocks children from requiring one

'navigation' build fails on:
  • fetch()/db call with no "use cache"        • cookies() / headers() / searchParams
  • connection()                                • use cache with expire &amp;lt; 5m, stale &amp;lt; 30s, or revalidate: 0
  • request-dependent promise passed to a Client Component
  • dynamic segment missing a complete generateStaticParams()

Inheritance:
  • child can set a stronger concrete level freely (shell → prefetch → navigation)
  • child exporting 'auto' is always compatible — defers to the ancestor's level, never fails the build
  • child exporting a weaker CONCRETE level than an ancestor already set → build fails
  • false + a level in the same tree → build fails
  • parallel routes rendered together must share the same value (or 'auto'/unset)

Not the same as: dynamic = 'force-static' (whole-route, pre-Cache-Components)
Not the same as: instant (dev-time signal, not an enforced build contract)
&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/nextjs-weekly-ensurestatic-locks-static-output/quiz" rel="noopener noreferrer"&gt;Take the 10-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;code&gt;ensureStatic&lt;/code&gt; turns "I'm pretty sure this route is static" into something the build actually checks, with four levels of increasing strength: &lt;code&gt;'auto'&lt;/code&gt;, &lt;code&gt;'shell'&lt;/code&gt;, &lt;code&gt;'prefetch'&lt;/code&gt;, &lt;code&gt;'navigation'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It requires Cache Components; &lt;code&gt;'shell'&lt;/code&gt; and &lt;code&gt;'prefetch'&lt;/code&gt; additionally require Partial Prefetching, while &lt;code&gt;'navigation'&lt;/code&gt; stands on Cache Components alone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'navigation'&lt;/code&gt; validates specifically: no uncached fetches, no runtime APIs, no short-lived cache entries, no request-scoped promises crossing into Client Components, and a complete &lt;code&gt;generateStaticParams()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A child can freely set a stronger level, and &lt;code&gt;'auto'&lt;/code&gt; on a child never conflicts — but explicitly exporting a weaker &lt;em&gt;concrete&lt;/em&gt; level than an ancestor already set fails the build, and parallel routes rendered together must agree.&lt;/li&gt;
&lt;li&gt;It's a regression guard for routes you've already made static, not a tool for making them static — and it's a different thing entirely from the dev-time &lt;code&gt;instant&lt;/code&gt; signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That cookie read someone adds to a shared header three weeks from now doesn't have to be a latency regression you discover in a dashboard. With &lt;code&gt;ensureStatic&lt;/code&gt; on the layout it depends on, it's a build failure with your name on the stack trace — which is a much better place to find it.&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-16-updatetag-vs-revalidatetag-what-changed-1mci"&gt;Next.js 16 updateTag vs revalidateTag: What Changed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/nextjs-proxyts-explained-with-cheat-sheet-j4i"&gt;Next.js proxy.ts Explained (with Cheat Sheet)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&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 — How to Cache in 16&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>caching</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Vue provide/inject: Why You Should Never Skip readonly()</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Mon, 02 Nov 2026 05:32:04 +0000</pubDate>
      <link>https://dev.to/parsajiravand/vue-provideinject-why-you-should-never-skip-readonly-48ce</link>
      <guid>https://dev.to/parsajiravand/vue-provideinject-why-you-should-never-skip-readonly-48ce</guid>
      <description>&lt;p&gt;A teammate asks you why &lt;code&gt;cart.total&lt;/code&gt; changed. You open the component that renders it — nothing there mutates it. You check its parent — nothing there either. Twenty minutes later you find it: a &lt;code&gt;&amp;lt;PromoBanner&amp;gt;&lt;/code&gt; three levels down, nowhere near the cart, called &lt;code&gt;cart.items.push(...)&lt;/code&gt; because it had a reference to the object and nobody stopped it. Vue didn't do anything wrong. &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; handed that component the exact same reactive object the cart store holds, and JavaScript let it write to what it was handed. The bug isn't in Vue's reactivity — it's in a contract nobody enforced.&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 an injected value is a live reference, not a snapshot or a message&lt;/li&gt;
&lt;li&gt;Reproduce the "mutation from anywhere" bug on purpose, so you recognize it in a real codebase&lt;/li&gt;
&lt;li&gt;Apply Vue's own fix — &lt;code&gt;readonly()&lt;/code&gt; on the provided value, paired with an exposed mutator function — and know exactly what it does and doesn't stop&lt;/li&gt;
&lt;li&gt;Type &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; with &lt;code&gt;InjectionKey&amp;lt;T&amp;gt;&lt;/code&gt; so TypeScript catches a missing provider at compile time&lt;/li&gt;
&lt;li&gt;Decide when &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; is still the right tool and when a store (Pinia) earns its weight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; you've used &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;reactive&lt;/code&gt;, and basic &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; in a Vue 3 Composition API app, and you want the version of this pattern that survives more than two components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why this breaks: provide/inject shares the object, not a copy&lt;/li&gt;
&lt;li&gt;The mental model&lt;/li&gt;
&lt;li&gt;The fix: readonly() and a mutator function&lt;/li&gt;
&lt;li&gt;Typing it with InjectionKey&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;p&gt;This is written against &lt;strong&gt;Vue 3.5&lt;/strong&gt; (verified &lt;code&gt;3.5.44&lt;/code&gt; on npm's &lt;code&gt;latest&lt;/code&gt; tag, October 2026). Everything here is Composition API with &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;; there is no Options API equivalent worth writing, since &lt;code&gt;this.$options.provide&lt;/code&gt; was always the weaker version of this same idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this breaks: provide/inject shares the object, not a copy
&lt;/h2&gt;

&lt;p&gt;Here's the setup almost every &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; tutorial shows, including Vue's own introductory example:&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="c"&gt;&amp;lt;!-- CartProvider.vue --&amp;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="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;reactive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provide&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;cart&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;total&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cart&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addItem&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- PromoBanner.vue, three components deep --&amp;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="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;inject&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;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart&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;applyFreeGift&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "I just need to add one thing, calling addItem felt like overkill"&lt;/span&gt;
  &lt;span class="nx"&gt;cart&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free-sticker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&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="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;Run this and it works. &lt;code&gt;cart.items&lt;/code&gt; updates, the cart badge re-renders, nothing throws. That's exactly why it's dangerous: there is no error to notice, no test that fails, no lint rule that fires. &lt;code&gt;inject("cart")&lt;/code&gt; returned the same &lt;code&gt;reactive()&lt;/code&gt; proxy the provider created — not a message describing a change, not a frozen snapshot — the object itself. Anything with a reference to it can write to it, from anywhere in the tree, and Vue's reactivity system will faithfully propagate that write, because propagating writes is its entire job.&lt;/p&gt;

&lt;p&gt;The bug only shows up later, as a debugging problem: &lt;code&gt;cart.total&lt;/code&gt; is wrong, or &lt;code&gt;cart.items&lt;/code&gt; has an entry nobody can explain, and the mutation could be coming from any of the components that called &lt;code&gt;inject("cart")&lt;/code&gt;. Vue devtools' state inspector will show you the current value, but it won't point you at the nine places across the tree that are allowed to change it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The mental model: &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; is Vue's version of passing a reference down an arbitrarily deep call stack — it shares the exact object, with none of the access control a function's parameters would give you.&lt;/strong&gt; A prop is already a one-way, shallow-readonly-by-convention handoff that Vue warns about if you mutate it directly. &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; has no equivalent warning built in, because the whole point of the API is to skip the explicit, typed handoff of props — and that convenience is also the hole.&lt;/p&gt;

&lt;p&gt;Vue's own guide is explicit about the fix, and about whose job it is: &lt;em&gt;"it is recommended to keep any mutations to reactive state inside of the provider whenever possible,"&lt;/em&gt; and when a descendant genuinely needs to change something, the provider should expose &lt;em&gt;"a function that is responsible for mutating the state"&lt;/em&gt; rather than the state's writable surface. That single line is doing a lot of work, and it's easy to skip because unlike a type error, skipping it doesn't break anything today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: readonly() and a mutator function
&lt;/h2&gt;

&lt;p&gt;The fix is two changes to the provider, and zero changes to how descendants read the value.&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="c"&gt;&amp;lt;!-- CartProvider.vue --&amp;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="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;reactive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readonly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provide&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;cart&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;total&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cart&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Descendants get a read-only view of the state...&lt;/span&gt;
&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;readonly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// ...and the only sanctioned way to change it.&lt;/span&gt;
&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addItem&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;Now the &lt;code&gt;PromoBanner&lt;/code&gt; from earlier doesn't quietly succeed:&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;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cart&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free-sticker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&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="c1"&gt;// [Vue warn] Set operation on key "0" failed: target is readonly.&lt;/span&gt;
&lt;span class="c1"&gt;// [Vue warn] Set operation on key "length" failed: target is readonly.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;readonly()&lt;/code&gt; wraps the value in a second proxy that intercepts writes and, in development, logs a warning naming the exact key that was targeted instead of silently applying — Vue's reactivity docs show this same message for a plain mutation attempt on a &lt;code&gt;readonly()&lt;/code&gt; copy of a reactive object. The write doesn't happen; the original, writable &lt;code&gt;cart&lt;/code&gt; inside the provider is unaffected. &lt;code&gt;PromoBanner&lt;/code&gt; has no way to change the cart except by calling the &lt;code&gt;addItem&lt;/code&gt; function it was also given — which means every mutation path is now a named function you can grep for, breakpoint, or log.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;readonly()&lt;/code&gt; doesn't make the &lt;em&gt;data&lt;/em&gt; immutable — the provider can still mutate its own &lt;code&gt;cart&lt;/code&gt; freely. It makes the &lt;em&gt;injected reference&lt;/em&gt; refuse writes, which is what turns "any of nine components might have done this" back into "only &lt;code&gt;addItem&lt;/code&gt; could have done this."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;readonly()&lt;/code&gt;'s wrapping is deep: nested objects and arrays reached through the readonly proxy are themselves readonly, so &lt;code&gt;cart.items.push(...)&lt;/code&gt; is caught, not just reassigning &lt;code&gt;cart.items&lt;/code&gt; itself. If you only need the top level protected (rare, and usually a sign the shape should be flatter), &lt;code&gt;shallowReadonly()&lt;/code&gt; exists, but reach for plain &lt;code&gt;readonly()&lt;/code&gt; by default — a one-level gap is exactly where the next &lt;code&gt;PromoBanner&lt;/code&gt; finds the one key that still writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typing it with InjectionKey
&lt;/h2&gt;

&lt;p&gt;String keys (&lt;code&gt;"cart"&lt;/code&gt;) work but give TypeScript nothing to check — a typo in the key, or injecting before the provider ever renders, both fail silently at runtime. &lt;code&gt;InjectionKey&amp;lt;T&amp;gt;&lt;/code&gt; fixes both:&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;// keys.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;InjectionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DeepReadonly&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="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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="nl"&gt;price&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;total&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Keyed as DeepReadonly&amp;lt;CartState&amp;gt;, because that's what provide(cartKey, readonly(cart))&lt;/span&gt;
&lt;span class="c1"&gt;// actually hands out — typing it as plain CartState fails to compile, since readonly()'s&lt;/span&gt;
&lt;span class="c1"&gt;// nested readonly arrays aren't assignable to CartState's mutable ones.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InjectionKey&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DeepReadonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart&lt;/span&gt;&lt;span class="dl"&gt;"&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;const&lt;/span&gt; &lt;span class="nx"&gt;addItemKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InjectionKey&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;items&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nc"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addItem&lt;/span&gt;&lt;span class="dl"&gt;"&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CartProvider.vue &amp;lt;script setup lang="ts"&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;reactive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provide&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addItemKey&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;CartState&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;./keys&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;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;total&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;items&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cart&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addItemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addItem&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PromoBanner.vue &amp;lt;script setup lang="ts"&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;inject&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addItemKey&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;./keys&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// cart: DeepReadonly&amp;lt;CartState&amp;gt; | undefined — TypeScript now forces you to&lt;/span&gt;
&lt;span class="c1"&gt;// handle the "no provider" case instead of finding it at 2am.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartKey&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;addItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addItemKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The symbol key means a typo can't collide with an unrelated &lt;code&gt;provide("cart", ...)&lt;/code&gt; elsewhere in the tree, and &lt;code&gt;inject()&lt;/code&gt;'s return type is now narrowed to &lt;code&gt;DeepReadonly&amp;lt;CartState&amp;gt; | undefined&lt;/code&gt; — the &lt;code&gt;undefined&lt;/code&gt; is real, because &lt;code&gt;inject&lt;/code&gt; without a default genuinely returns &lt;code&gt;undefined&lt;/code&gt; when there's no matching provider above the component, and the type system now makes you prove you've considered that.&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;readonly()&lt;/code&gt; on a &lt;code&gt;ref&lt;/code&gt; warns the same way.&lt;/strong&gt; &lt;code&gt;provide("count", readonly(count))&lt;/code&gt; means &lt;code&gt;inject("count").value = 5&lt;/code&gt; from a descendant logs the same "target is readonly" warning instead of applying. The pattern isn't specific to &lt;code&gt;reactive()&lt;/code&gt; objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;computed()&lt;/code&gt; needs no wrapping.&lt;/strong&gt; A computed ref is already read-only by construction — assigning to &lt;code&gt;.value&lt;/code&gt; is a TypeScript error (or a runtime no-op without a custom setter) regardless of &lt;code&gt;readonly()&lt;/code&gt;. If the provided value is derived, not stored, provide the &lt;code&gt;computed()&lt;/code&gt; directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The provider can still forget the function.&lt;/strong&gt; &lt;code&gt;readonly()&lt;/code&gt; stops the &lt;em&gt;descendant&lt;/em&gt; from writing; it does nothing if the provider itself exports a mutable reference alongside the readonly one by mistake. The discipline is "provide state read-only, provide every allowed change as a named function" — both halves matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is a dev-mode warning, not a runtime guarantee you should rely on for security.&lt;/strong&gt; It protects you from accidental mutation in your own codebase, not from a determined caller bypassing Vue's proxy in production. Don't use it as an access-control boundary for untrusted code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's the single-request version of the shared-state trap.&lt;/strong&gt; The same "one object, many writers" shape that bites &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; across a component tree also bites module-scope state across &lt;em&gt;requests&lt;/em&gt; on a Vue SSR server — that's a different failure mode with a different fix, covered in this series' &lt;a href="https://dev.to/parsajiravand/vue-composables-the-shared-state-trap-cheat-sheet-37ia"&gt;composables shared-state piece&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reach for &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; (with this pattern) when&lt;/strong&gt; the state is genuinely tree-scoped — a form wizard's shared step state, a modal's context, a design-system component's configuration — and you want to avoid prop-drilling it through components that don't care about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pair every &lt;code&gt;provide()&lt;/code&gt; of mutable state with:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;readonly()&lt;/code&gt; (or &lt;code&gt;shallowReadonly()&lt;/code&gt;) around the value itself.&lt;/li&gt;
&lt;li&gt;One or more named functions for every change a descendant is allowed to make.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;InjectionKey&amp;lt;T&amp;gt;&lt;/code&gt; instead of a string, once more than one file touches the key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Reach for Pinia instead when&lt;/strong&gt; the state needs to be read or changed from components that aren't descendants of a single provider — a cart that's also read from the header nav and a checkout page outside that subtree, for instance. &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; is scoped to a component subtree by design; a store isn't. Using &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; for truly global, cross-tree state usually means re-providing it near the root anyway, at which point a store does the same job with less wiring.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does readonly() also stop the provider from mutating the value?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;readonly(cart)&lt;/code&gt; returns a &lt;em&gt;new&lt;/em&gt; proxy; the original &lt;code&gt;cart&lt;/code&gt; reference the provider holds is still the plain &lt;code&gt;reactive()&lt;/code&gt; object, fully writable. Only the reference that goes through &lt;code&gt;provide()&lt;/code&gt; is wrapped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does wrapping a large object in readonly() cost performance?
&lt;/h3&gt;

&lt;p&gt;The wrapping itself is cheap — it's a proxy, created once, not a deep clone. Reads are tracked the same way &lt;code&gt;reactive()&lt;/code&gt; reads are. You won't notice this on anything short of a very large, deeply nested object being read in a tight loop, and that's a different problem to measure separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a child still mutate the state through a method it calls?
&lt;/h3&gt;

&lt;p&gt;Yes, and that's correct, not a hole. If the provider exposes &lt;code&gt;addItem()&lt;/code&gt;, a child calling &lt;code&gt;addItem()&lt;/code&gt; is mutating state through the one sanctioned path — that's the entire point. &lt;code&gt;readonly()&lt;/code&gt; blocks direct property writes on the injected value; it was never meant to block the mutator functions you provide alongside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this replace Pinia?
&lt;/h3&gt;

&lt;p&gt;No — it's the right tool for state scoped to one part of the tree, not a general store replacement. See "Best practices" above for the line between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a component injects a key with no matching provider?
&lt;/h3&gt;

&lt;p&gt;Without a default, &lt;code&gt;inject()&lt;/code&gt; returns &lt;code&gt;undefined&lt;/code&gt; at runtime. With &lt;code&gt;InjectionKey&amp;lt;T&amp;gt;&lt;/code&gt;, TypeScript types that return as &lt;code&gt;T | undefined&lt;/code&gt;, so a missing provider becomes a type error you fix during development instead of a crash a user finds. You can also pass a default as &lt;code&gt;inject(key, defaultValue)&lt;/code&gt;.&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;Task&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provide read-only state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;provide(key, readonly(state))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deep by default; descendants can read, not write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provide the allowed mutation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;provide(mutateKey, mutateFn)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One named function per allowed change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type the key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;const key: InjectionKey&amp;lt;T&amp;gt; = Symbol("x")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;inject(key)&lt;/code&gt; is now typed `T \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inject with a safe default&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;inject(key, fallback)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Avoids &lt;code&gt;undefined&lt;/code&gt; entirely when a sane default exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top-level-only protection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;shallowReadonly(state)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rare — nested objects stay mutable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readonly a single ref&lt;/td&gt;
&lt;td&gt;&lt;code&gt;provide(key, readonly(myRef))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same warning-on-write behavior as an object&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// keys.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;InjectionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DeepReadonly&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="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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="nl"&gt;price&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;total&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;const&lt;/span&gt; &lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InjectionKey&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DeepReadonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cart&lt;/span&gt;&lt;span class="dl"&gt;"&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;const&lt;/span&gt; &lt;span class="nx"&gt;addItemKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InjectionKey&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;items&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addItem&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Provider&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;total&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CartState&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;items&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cart&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addItemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Any descendant&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// DeepReadonly&amp;lt;CartState&amp;gt; | undefined&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addItemKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the only legal way to change it&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/vue-weekly-provide-inject-mutation-trap/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-provide-inject-mutation-trap/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;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; hands descendants the live reactive object, not a copy — any component with the reference can mutate it, and Vue will propagate that write exactly as designed.&lt;/li&gt;
&lt;li&gt;Vue's own guide already tells you the fix: keep mutations in the provider, and provide a function for anything a descendant is allowed to change. The gap is that most example code skips it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;readonly()&lt;/code&gt; (or &lt;code&gt;shallowReadonly()&lt;/code&gt;) turns a silent, untraceable write into a dev-mode warning naming the exact key — wrap every provided value that descendants shouldn't mutate directly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InjectionKey&amp;lt;T&amp;gt;&lt;/code&gt; replaces a magic string with a typed, typo-proof key and makes the "no provider above me" case a compile-time &lt;code&gt;| undefined&lt;/code&gt; instead of a runtime surprise.&lt;/li&gt;
&lt;li&gt;This isn't a Pinia replacement — it's the right shape for state scoped to one part of the tree; state read or written from outside that subtree belongs in a store instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That &lt;code&gt;PromoBanner&lt;/code&gt; bug from the top is now impossible to write by accident: there's no writable &lt;code&gt;cart&lt;/code&gt; to reach for, only an &lt;code&gt;addItem&lt;/code&gt; call that shows up in a stack trace with your name on it. The next time &lt;code&gt;inject()&lt;/code&gt; hands back an object, ask the one question this article is about: can whoever holds this write to it, and did you mean for them to?&lt;/p&gt;

&lt;p&gt;What's the messiest &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; mutation bug you've chased down? Drop it in the comments — there's a good chance someone else is debugging the exact same shape 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/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-definemodel-the-v-model-contract-explained-fc1"&gt;Vue defineModel: The v-model Contract, Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/vue-watch-vs-watcheffect-the-complete-guide-2f40"&gt;Vue watch vs watchEffect: The Complete Guide&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>typescript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Deleted My Scroll-Lock Library's Ugly Hack. `scrollbar-gutter` Replaces It.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Mon, 02 Nov 2026 05:31:33 +0000</pubDate>
      <link>https://dev.to/parsajiravand/i-deleted-my-scroll-lock-librarys-ugly-hack-scrollbar-gutter-replaces-it-42bh</link>
      <guid>https://dev.to/parsajiravand/i-deleted-my-scroll-lock-librarys-ugly-hack-scrollbar-gutter-replaces-it-42bh</guid>
      <description>&lt;p&gt;You open a modal. For one frame, before you can even read what it says, the page behind it flinches — the whole column of text shifts a few pixels to the right, like the browser flinched too.&lt;/p&gt;

&lt;p&gt;Close the modal and it snaps back. Nobody styled that. Nobody asked for it. And if you build UI on Windows or Linux, you've shipped it to production without noticing, because on a Mac with the default trackpad scrollbars, it's nearly invisible. That's the part that makes this bug so sticky: the person who can see it and the person who has to fix it are usually not using the same operating system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone reaches for first
&lt;/h2&gt;

&lt;p&gt;The standard explanation is that &lt;code&gt;overflow: hidden&lt;/code&gt; on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; — the thing every modal, drawer, and dropdown uses to stop the page scrolling behind it — also removes the vertical scrollbar. Removing the scrollbar hands its width back to the content. The content reflows into that space. The page shifts.&lt;/p&gt;

&lt;p&gt;So people fix it the only way CSS used to allow: measure the scrollbar, then pay it back in padding.&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;function&lt;/span&gt; &lt;span class="nf"&gt;lockScroll&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;scrollbarWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerWidth&lt;/span&gt; &lt;span class="o"&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;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paddingRight&lt;/span&gt; &lt;span class="o"&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;scrollbarWidth&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unlockScroll&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paddingRight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&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;This works. It's also the entire reason &lt;code&gt;body-scroll-lock&lt;/code&gt;, &lt;code&gt;react-remove-scroll&lt;/code&gt;, and half a dozen modal libraries ship their own copy of this exact measurement. Every one of them is solving the same problem: find out how wide the scrollbar &lt;em&gt;would have been&lt;/em&gt;, then fake its presence with padding so nothing moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading — guess how many pixels of padding a 100-line copy-pasted "scroll lock" utility is usually off by, on the one OS nobody tested it on.&lt;/strong&gt; The answer is almost always "enough to notice," because scrollbar width isn't a constant. It's roughly 17px on Windows across Chrome and Firefox alike, 0px on a Mac with overlay scrollbars — and the measurement has to run &lt;em&gt;before&lt;/em&gt; you lock, every single time, or it reads the wrong number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the shift happens at all
&lt;/h2&gt;

&lt;p&gt;Here's the part the padding hack never actually fixes, it just hides: a &lt;em&gt;classic&lt;/em&gt; scrollbar — the opaque kind with a visible track, standard on Windows and Linux — is laid out inside the element's content box. The browser treats it the same way it treats a sidebar: it eats width from whatever would otherwise render there. Turn the scrollbar off, and the content box grows back to full width. Turn it on, and the content box shrinks to make room. The content isn't "jumping" — it's being resized, every time, and resized fast enough that it reads as a flinch.&lt;/p&gt;

&lt;p&gt;macOS's default overlay scrollbars don't do this. They float on top of the content without claiming any layout space, so toggling them on and off never changes anything's width. That's the whole reason this bug is invisible to a developer who only ever tests on their own Mac: the platform they build on structurally can't reproduce the thing their users are reporting.&lt;/p&gt;

&lt;p&gt;The padding-right hack works around the resize by adding back exactly the width the scrollbar would have taken — but it only works if you already know the scrollbar is about to disappear, and you measured it correctly, before it does. It's compensating for a resize instead of preventing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;scrollbar-gutter: stable&lt;/code&gt;: tell the layout to stop flinching
&lt;/h2&gt;

&lt;p&gt;CSS has a property for exactly this now, and the name says what it does: reserve the scrollbar's gutter, &lt;em&gt;stably&lt;/em&gt;, whether a scrollbar is currently needed or not.&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="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;scrollbar-gutter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stable&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;Set this once, and the browser carves out the scrollbar's width in the layout permanently — for &lt;code&gt;overflow: auto&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, &lt;em&gt;and&lt;/em&gt; &lt;code&gt;hidden&lt;/code&gt;. When your modal flips &lt;code&gt;overflow&lt;/code&gt; to &lt;code&gt;hidden&lt;/code&gt; to lock the page, the gutter is already reserved from before the modal ever opened. There's no width to give back, so there's nothing to resize, so there's nothing to flinch.&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;function&lt;/span&gt; &lt;span class="nf"&gt;lockScroll&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// that's it — no measuring, no padding&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;unlockScroll&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&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;That's the whole fix for the layout-shift half of what a scroll-lock library does. You still need something to stop the page actually scrolling under a modal on touch devices — &lt;code&gt;overflow: hidden&lt;/code&gt; alone doesn't catch every edge case there, and that part of a scroll-lock library can stay. But the width-measuring, padding-compensating dance that most of these libraries built their reputation on? &lt;code&gt;scrollbar-gutter: stable&lt;/code&gt; makes it dead code.&lt;/p&gt;

&lt;p&gt;There's a second value for layouts that center content and need symmetry on both sides:&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="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;scrollbar-gutter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stable&lt;/span&gt; &lt;span class="n"&gt;both-edges&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;code&gt;both-edges&lt;/code&gt; mirrors the reserved gutter to the opposite inline edge, so if space is held open on the right, matching space opens on the left too — useful when a scrollbar appearing on only one side would visibly knock a centered layout off-center.&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/scrollbar-gutter-layout-shift/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 part that's easy to get wrong: overlay scrollbars opt out of this too
&lt;/h2&gt;

&lt;p&gt;Here's the honest asterisk, and it's the same one from earlier in reverse: &lt;code&gt;scrollbar-gutter: stable&lt;/code&gt; only reserves space for &lt;em&gt;classic&lt;/em&gt; scrollbars. MDN says it plainly — "When using overlay scrollbars, the gutter will not be present." On a system with overlay scrollbars, setting this property changes nothing, because there was never any layout space being given up in the first place.&lt;/p&gt;

&lt;p&gt;That means you can't fully verify this fix by eyeballing your own Mac. You need a Windows or Linux machine, a Windows/Linux VM, or Chrome DevTools' device toolbar won't help either — it emulates viewport size, not the host OS's scrollbar rendering mode. The honest test is someone, or something, running an actual classic-scrollbar environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this safe to ship today?
&lt;/h2&gt;

&lt;p&gt;Yes, with one small caveat worth knowing rather than discovering. Chrome and Edge have supported &lt;code&gt;scrollbar-gutter&lt;/code&gt; since version 94 (2021), and Firefox since version 97 (2022) — this has quietly worked for most of your users for years. Safari was the holdout; it landed support in Safari 18.2, released in December 2024, which is also what pushed &lt;code&gt;scrollbar-gutter&lt;/code&gt; to Baseline "newly available" that same month. If you need to support Safari versions older than 18.2, the property is simply ignored there — no error, no warning, the page just behaves exactly like it always did. That's a safe degrade, not a broken one, so there's no reason to gate this behind a feature query unless you want to show a different UI specifically for browsers that lack it.&lt;/p&gt;

&lt;p&gt;The next time a modal makes your page twitch, you'll know exactly why — and now you know the one-line reason to stop paying JavaScript to measure a number CSS can just reserve for you.&lt;/p&gt;

&lt;p&gt;Still carrying a scroll-lock library in your bundle for this exact problem? Go check what it actually does under the hood — there's a decent chance half of it just became unnecessary.&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/scrollbar-gutter-layout-shift/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;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/stop-using-a-js-masonry-library-css-grid-lanes-does-it-natively-n42"&gt;Stop Using a JS Masonry Library. CSS Grid Lanes Does It Natively.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/stop-writing-mathcos-in-a-resize-listener-2ne2"&gt;Stop Writing Math.cos() in a Resize Listener&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/your-expand-all-button-is-quietly-causing-layout-thrashing-2lpj"&gt;Your 'Expand All' Button Is Quietly Causing Layout Thrashing&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>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>javascript</category>
    </item>
    <item>
      <title>React Activity Component: The State Between Render and Remount</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Sun, 01 Nov 2026 18:40:36 +0000</pubDate>
      <link>https://dev.to/parsajiravand/react-activity-component-the-state-between-render-and-remount-5d0p</link>
      <guid>https://dev.to/parsajiravand/react-activity-component-the-state-between-render-and-remount-5d0p</guid>
      <description>&lt;p&gt;You hide a settings panel with &lt;code&gt;{isOpen &amp;amp;&amp;amp; &amp;lt;Panel /&amp;gt;}&lt;/code&gt;. The user reopens it two seconds later, and the unsaved toggle they'd flipped is back to its default. The scroll position is gone. The debounced search they'd typed into a nested filter has vanished. Nothing is broken — React unmounted &lt;code&gt;&amp;lt;Panel /&amp;gt;&lt;/code&gt; and mounted a brand new one, exactly as that code tells it to. You just didn't mean to ask for that.&lt;/p&gt;

&lt;p&gt;Before React 19.2, there was no built-in way to say "hide this, but keep it exactly as it was." CSS (&lt;code&gt;display: none&lt;/code&gt; while the component stays mounted) keeps the state, but its effects keep running in the background — timers ticking, subscriptions open, polling a screen nobody can see. Unmounting runs cleanup correctly but throws the state away. There was no option between "alive and visible" and "gone."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React 19.2&lt;/strong&gt; (released October 2025) added &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; to close that gap: a built-in way to hide a subtree while keeping its state intact. &lt;strong&gt;React 19.3&lt;/strong&gt; — released September 2026 and patched to the current npm &lt;code&gt;latest&lt;/code&gt;, &lt;strong&gt;19.3.1&lt;/strong&gt;, in October — shipped several bug fixes for it, including one around &lt;code&gt;useSyncExternalStore&lt;/code&gt; updates while hidden, that are worth understanding on their own, because they show exactly where the "hidden but alive" model gets subtle. This is episode eight of &lt;strong&gt;React Deep Dive&lt;/strong&gt;, and it picks up where &lt;a href="https://dev.to/parsajiravand/react-re-render-vs-remount-what-actually-triggers-each-5fok"&gt;episode one, on re-render vs. remount&lt;/a&gt;, left off: &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; is neither of those two things. It's a third state, with its own rules, and treating it like either of the other two is exactly how the 19.3 bugs happened.&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 precisely what &lt;code&gt;&amp;lt;Activity mode="hidden"&amp;gt;&lt;/code&gt; preserves and what it tears down — state, effects, refs, and renders are four separate questions, not one&lt;/li&gt;
&lt;li&gt;Replace a &lt;code&gt;{condition &amp;amp;&amp;amp; &amp;lt;Panel/&amp;gt;}&lt;/code&gt; pattern with &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; when hiding, not destroying, is what you actually want&lt;/li&gt;
&lt;li&gt;Predict when a hidden subtree still re-renders (and why that's not a bug)&lt;/li&gt;
&lt;li&gt;Avoid the two traps that the 19.3 patch notes had to fix for the React team itself&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;&amp;lt;StrictMode&amp;gt;&lt;/code&gt; to catch the effect-cleanup bugs that &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; surfaces&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You're comfortable with function components, &lt;code&gt;useState&lt;/code&gt;, and &lt;code&gt;useEffect&lt;/code&gt;, and you've been bitten at least once by state resetting when you only meant to hide something. You don't need to have used &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; before.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: hiding isn't unmounting, and unmounting isn't hiding&lt;/li&gt;
&lt;li&gt;The mental model&lt;/li&gt;
&lt;li&gt;The walkthrough&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: hiding isn't unmounting, and unmounting isn't hiding
&lt;/h2&gt;

&lt;p&gt;Say you're building a dashboard with three report tabs. Each tab has its own view: a filter form, a live-updating counter from a &lt;code&gt;useEffect&lt;/code&gt;-driven interval (think "records processed," polled every second), and a draft note the user is typing. Switch tabs, and here's the naive version:&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;Dashboard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;activeTab&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sales&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Sales"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ops&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Ops"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Support"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;This works, in the sense that it compiles and nothing throws. But switch from "Sales" to "Ops" and back, and the Sales &lt;code&gt;ReportPanel&lt;/code&gt; is a &lt;strong&gt;completely new instance&lt;/strong&gt;. Its counter restarts at zero. Its draft note is empty. Its &lt;code&gt;useEffect&lt;/code&gt; cleanup ran when you left, and a fresh mount effect ran when you came back — including any expensive setup it does on mount. You wanted a tab switch; React gave you a demolition and a rebuild.&lt;/p&gt;

&lt;p&gt;The instinctive fix is to keep everything mounted and hide the inactive panels with CSS:&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;Dashboard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;activeTab&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sales&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;block&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;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Sales"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ops&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;block&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;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Ops"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support&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;block&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;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Support"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;This keeps the state, but now you've traded one bug for another: the Sales panel's interval keeps polling, its effects keep subscribing, and its hidden DOM keeps costing you layout and memory for every tab the user isn't looking at. You've solved the state problem by un-solving the cleanup problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; is React's answer to "I want both": state survives, but effects still tear down and rebuild the way they would for a component you actually cared about keeping correct.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; Render, remount, and &lt;code&gt;&amp;lt;Activity mode="hidden"&amp;gt;&lt;/code&gt; are three different operations, and they disagree on three separate things — &lt;strong&gt;state&lt;/strong&gt;, &lt;strong&gt;effects&lt;/strong&gt;, and &lt;strong&gt;rendering&lt;/strong&gt; — not just one axis you can call "aliveness."&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;Stays mounted (same instance)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;&amp;lt;Activity mode="hidden"&amp;gt;&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Unmount (remove from the tree)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;State&lt;/strong&gt; (&lt;code&gt;useState&lt;/code&gt;, &lt;code&gt;useReducer&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Preserved&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Destroyed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Effects&lt;/strong&gt; (&lt;code&gt;useEffect&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Keep running&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Torn down on hide, re-created on show&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Torn down, never re-created (new instance builds fresh ones)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rendering on prop/context change&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Happens normally&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Still happens, at lower priority&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Doesn't happen — there's nothing there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Visible&lt;/td&gt;
&lt;td&gt;Hidden (&lt;code&gt;display: none&lt;/code&gt;), node kept&lt;/td&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Point at the live node&lt;/td&gt;
&lt;td&gt;Still point at the (now hidden) node&lt;/td&gt;
&lt;td&gt;Become &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the middle column again: hidden is not the same as paused, and it is not the same as destroyed. A component inside a hidden &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; keeps its state, loses its effects, and — this is the part people get wrong — &lt;strong&gt;keeps rendering&lt;/strong&gt; when something above it changes, just at a priority React considers less urgent than whatever's actually on screen. That last row is the one the 19.3 patch notes had to fix bugs around, because it's the one that breaks the intuition "hidden means nothing happens here."&lt;/p&gt;

&lt;p&gt;Here's the shape in code, next to the two things it's between:&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;Activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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;Dashboard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;activeTab&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sales&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;visible&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;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Sales"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ops&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;visible&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;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Ops"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;activeTab&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support&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;visible&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;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportPanel&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Support"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;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;Switch tabs now, and the inactive &lt;code&gt;ReportPanel&lt;/code&gt;s stay mounted as far as state is concerned (the draft note survives), but their effects clean up (the polling interval stops) the moment they go hidden, and start fresh again the moment they're shown. No wasted polling, no lost draft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Historical aside, labelled:&lt;/strong&gt; before 19.2 stabilized this, an experimental build exposed the same idea as &lt;code&gt;unstable_Activity&lt;/code&gt;. If you see &lt;code&gt;import { unstable_Activity as Activity } from "react"&lt;/code&gt; in an older blog post or a pinned pre-19.2 dependency, that's the pre-stable name — don't write it in new code. The stable export is just &lt;code&gt;Activity&lt;/code&gt;, no prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — the smallest correct example
&lt;/h3&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;Activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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;Sidebar&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;isOpen&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFilter&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visible&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;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;setFilter&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;target&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;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; &lt;code&gt;mode&lt;/code&gt; is the entire API surface for visibility — &lt;code&gt;"visible"&lt;/code&gt; or &lt;code&gt;"hidden"&lt;/code&gt; (it defaults to &lt;code&gt;"visible"&lt;/code&gt; if you omit it). There's no &lt;code&gt;mode="unmounted"&lt;/code&gt;; if you want a true unmount, don't render the &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; at all. &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; only ever answers "show or hide," never "destroy."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — proving the effect actually tears down
&lt;/h3&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;ReportPanel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;label&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="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;setCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setDraft&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&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;setCount&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&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="mi"&gt;1000&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="nf"&gt;clearInterval&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="c1"&gt;// runs when Activity hides this subtree&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;count&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;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;setDraft&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;target&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Hide this panel's &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; and the interval's cleanup runs immediately — &lt;code&gt;count&lt;/code&gt; stops climbing. Show it again, and the effect's setup function runs again, starting a new interval. &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;draft&lt;/code&gt; were never touched by any of this; they're still sitting in React's fiber for this component, exactly where you left them. The playground below is this exact component, running the real reconciler, so you can watch the counter freeze and the draft survive in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — the row people miss: hidden subtrees still render
&lt;/h3&gt;

&lt;p&gt;This is the one the mental model table flags and most tutorials skip. A hidden &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; doesn't freeze its children — it keeps re-rendering them when their props or context change, just scheduled at a lower priority than the visible UI. Why would React bother? Two real reasons, both from the official docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-rendering.&lt;/strong&gt; A &lt;code&gt;&amp;lt;Activity mode="hidden"&amp;gt;&lt;/code&gt; on &lt;em&gt;initial&lt;/em&gt; render still renders its children (without mounting their effects), which lets data-fetching components start loading before the user ever opens that tab — so when they do, it's instant instead of a fresh Suspense fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping derived UI correct.&lt;/strong&gt; If a hidden panel's rendered output depends on a prop from its parent (say, a shared "last synced at" timestamp), it should show the right value the instant it's revealed, not a stale one from the moment it was hidden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The playground's "Bump shared prop" button demonstrates this directly: it changes a value passed into every &lt;code&gt;ReportPanel&lt;/code&gt;, visible or hidden, and the hidden panel's render count still climbs — while its effect-mount count does not. Those two counters moving independently &lt;em&gt;is&lt;/em&gt; the mental model, observed instead of described.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — what the 19.3 bug fixes actually tell you
&lt;/h3&gt;

&lt;p&gt;React 19.3's changelog lists several &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; fixes: issues with &lt;code&gt;useSyncExternalStore&lt;/code&gt; updates while hidden, portal visibility, metadata hoisting, and error handling. The pattern across them is instructive: every one is a place where "still rendering, but hidden, with effects torn down" created a seam that a feature built on a simpler always-mounted/never-mounted assumption fell through. &lt;code&gt;useSyncExternalStore&lt;/code&gt; subscribes to an external store through an effect-like mechanism — exactly what &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; tears down on hide — so an update arriving while hidden needs special handling to avoid being silently lost or double-applied on reveal. If you subscribe to anything external (WebSockets, browser APIs, a state manager outside React) inside a component that might live under an &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt;, assume the subscription is gone while hidden and gets rebuilt from current state when shown.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Text-only children render nothing when hidden.&lt;/strong&gt; Hiding works by applying &lt;code&gt;display: "none"&lt;/code&gt; to a wrapper — bare text has no element to attach that to, so it just doesn't render. Wrap bare text in an element if it might end up inside an &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side effects outside &lt;code&gt;useEffect&lt;/code&gt; keep running.&lt;/strong&gt; A &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt;, or &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; isn't paused by hiding — its DOM node is still alive, still doing whatever it was doing. Pause it explicitly, in a &lt;code&gt;useLayoutEffect&lt;/code&gt; cleanup tied to the visual hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refs still point at hidden DOM.&lt;/strong&gt; Unmounting nulls a ref; hiding doesn't — the node still exists, just invisible. Code that treats "ref is set" as "user can see it" will be wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;StrictMode is how you catch the bugs this exposes.&lt;/strong&gt; Effects now mount and unmount far more often than a typical lifetime suggests — every hide/show cycle, not just mount/unmount. Any effect that assumed its cleanup never really had to undo everything gets exposed fast. Develop with &lt;code&gt;&amp;lt;StrictMode&amp;gt;&lt;/code&gt; so this surfaces before production does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interaction with &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;.&lt;/strong&gt; If an &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; reveal happens inside &lt;code&gt;startTransition&lt;/code&gt;, and the tree uses &lt;a href="https://dev.to/parsajiravand/react-193-viewtransition-animate-state-without-losing-it-4kpp"&gt;&lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;&lt;/a&gt; (also stable in 19.3), showing it triggers that boundary's &lt;code&gt;enter&lt;/code&gt; animation and hiding triggers &lt;code&gt;exit&lt;/code&gt; — the two features compose by design.&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;&amp;lt;Activity&amp;gt;&lt;/code&gt; when state is expensive to rebuild and the hide is temporary&lt;/strong&gt; — tabs, a collapsible sidebar, a wizard step, a modal reopened with the same draft. The signal: the user will plausibly come back to this exact state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't make it a default replacement for conditional rendering.&lt;/strong&gt; If the user is done with something for good (a dismissed notification, a finished onboarding step), unmounting is correct and cheaper — nothing left to render at low priority, nothing to reconcile later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't treat it as a pause button for real-world side effects&lt;/strong&gt; (payments in flight, a write to a server). Hiding a subtree doesn't cancel an in-flight promise; it only affects effect lifecycle and state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a hidden subtree subscribes to anything external&lt;/strong&gt;, write its effect assuming the subscription is gone while hidden — resync from current state on the next mount rather than assuming buffered delivery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;mode&lt;/code&gt; a &lt;code&gt;useState&lt;/code&gt;/prop-driven value&lt;/strong&gt;, not something you mutate imperatively — &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; reacts to re-renders like anything else in React.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; the same thing as &lt;code&gt;display: none&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;No. It uses &lt;code&gt;display: none&lt;/code&gt; for the visual hiding, but that's one of four things it does — it also tears down effects (plain CSS never does), preserves state across the hide (an unmount never does), and keeps rendering hidden children at lower priority (a component you've stopped rendering can't do at all).&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; replace &lt;code&gt;key&lt;/code&gt;-based remounting?
&lt;/h3&gt;

&lt;p&gt;No — opposite problems. &lt;code&gt;key&lt;/code&gt; forces React to treat something as a &lt;em&gt;new&lt;/em&gt; instance, which is right when you want fresh state. &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; keeps the &lt;em&gt;same&lt;/em&gt; instance alive while hidden. Want a reset? Use &lt;code&gt;key&lt;/code&gt;. Want to preserve exactly what's there? Use &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; fetch data for a hidden tab before the user opens it?
&lt;/h3&gt;

&lt;p&gt;Under specific conditions: if the hidden children read data through something Suspense-activating — a promise read with &lt;code&gt;use()&lt;/code&gt; — a hidden &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; can pre-render and start that fetch early. Data fetched inside a &lt;code&gt;useEffect&lt;/code&gt; is not: effects don't run while hidden, so effect-based fetching only starts once the subtree becomes visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does hiding with &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; cost anything if I never show the content again?
&lt;/h3&gt;

&lt;p&gt;Some — the hidden subtree still occupies a spot in the fiber tree and can still re-render at low priority. For content the user is realistically done with, unmount it; reach for &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; specifically because you expect a return visit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I nest &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; components?
&lt;/h3&gt;

&lt;p&gt;Yes — visibility composes as expected. A &lt;code&gt;visible&lt;/code&gt; &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; inside a &lt;code&gt;hidden&lt;/code&gt; one is still effectively hidden, because its ancestor is hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat sheet
&lt;/h2&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;Activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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="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="c1"&gt;// 1. Import — stable since React 19.2, no prefix.&lt;/span&gt;
&lt;span class="c1"&gt;// import { unstable_Activity as Activity } from "react"; // ❌ pre-19.2 only&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Usage — mode is the whole API.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isVisible&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visible&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;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExpensiveSubtree&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// 3. What survives hiding vs. what doesn't:&lt;/span&gt;
&lt;span class="c1"&gt;//    state (useState/useReducer)         → preserved&lt;/span&gt;
&lt;span class="c1"&gt;//    effects (useEffect)                 → torn down on hide, re-created on show&lt;/span&gt;
&lt;span class="c1"&gt;//    rendering on prop/context change    → still happens, lower priority&lt;/span&gt;
&lt;span class="c1"&gt;//    DOM node                            → kept, display: none&lt;/span&gt;
&lt;span class="c1"&gt;//    refs                                → still point at the (hidden) node&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Pick the right tool:&lt;/span&gt;
&lt;span class="c1"&gt;//    "destroy it for good"        → don't render it (plain conditional)&lt;/span&gt;
&lt;span class="c1"&gt;//    "reset it on purpose"        → key={newId}&lt;/span&gt;
&lt;span class="c1"&gt;//    "hide it, keep it alive"     → &amp;lt;Activity mode="hidden"&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// 5. Gotchas:&lt;/span&gt;
&lt;span class="c1"&gt;//    - bare text child while hidden renders nothing (no element to hide)&lt;/span&gt;
&lt;span class="c1"&gt;//    - &amp;lt;video&amp;gt;/&amp;lt;audio&amp;gt;/&amp;lt;iframe&amp;gt; keep running while hidden — pause explicitly&lt;/span&gt;
&lt;span class="c1"&gt;//      in a useLayoutEffect cleanup if that matters&lt;/span&gt;
&lt;span class="c1"&gt;//    - external subscriptions (useSyncExternalStore, sockets) are torn down&lt;/span&gt;
&lt;span class="c1"&gt;//      on hide — resync on remount, don't assume buffered delivery&lt;/span&gt;
&lt;span class="c1"&gt;//    - develop with &amp;lt;StrictMode&amp;gt; to catch effects that assumed they'd only&lt;/span&gt;
&lt;span class="c1"&gt;//      ever run once&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;&amp;lt;Activity mode="hidden"&amp;gt;&lt;/code&gt; is a third state, not a point on the line between "mounted" and "unmounted": state is preserved, effects are torn down and rebuilt, and rendering on prop change still happens — just at lower priority.&lt;/li&gt;
&lt;li&gt;It solves the specific problem of keeping expensive-to-rebuild state alive across a hide/show cycle without paying the cost of a fully-live, always-rendering component.&lt;/li&gt;
&lt;li&gt;The 19.3 bug fixes for &lt;code&gt;useSyncExternalStore&lt;/code&gt;, portals, and metadata under &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; all trace back to the same seam: features built assuming "mounted" or "unmounted" as the only two states had to be taught about the third one.&lt;/li&gt;
&lt;li&gt;Reach for it when the user will plausibly come back; reach for &lt;code&gt;key&lt;/code&gt; when you want a clean reset; reach for plain conditional rendering when the content is genuinely done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That chat-draft bug from episode one and the dashboard tab bug here are mirror images of each other — one was an accidental remount nobody wanted, this one is an accidental unmount nobody wanted. Same underlying lesson both times: React only does what your JSX actually asks for, never what you meant. Have you reached for &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; yet, or are you still hiding things with CSS and hoping the effects behave? Tell me what you're using it for 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/react-weekly-activity-component/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/react-weekly-activity-component/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;
  
  
  📚 Read next
&lt;/h2&gt;

&lt;ul&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-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-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>Stop Hardcoding Color Variants. Relative Color Syntax Does It.</title>
      <dc:creator>Parsa J</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:57:09 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-hardcoding-color-variants-relative-color-syntax-does-it-1f8k</link>
      <guid>https://dev.to/parsajiravand/stop-hardcoding-color-variants-relative-color-syntax-does-it-1f8k</guid>
      <description>&lt;p&gt;The PR comment was two words: "wrong blue." Not a bug report, not a reproduction — a designer scrolling through a staging build, stopping on the "Upgrade" button's hover state, and flagging it because something about it felt off against the rest of the rebrand that had shipped two weeks earlier.&lt;/p&gt;

&lt;p&gt;They were right, and it took longer than it should have to find out why. The button's resting color matched the new brand blue exactly — that token had been updated the day the rebrand landed. Its hover color was still the &lt;em&gt;old&lt;/em&gt; brand blue, darkened. Nobody had touched it, because nobody remembered it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five variables, one of which actually gets updated
&lt;/h2&gt;

&lt;p&gt;Open almost any design system's color file and you'll find the same shape: one brand color, and a handful of hand-picked variants sitting next to it that all &lt;em&gt;look&lt;/em&gt; derived but are actually just... more hex codes.&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="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#3b6fd6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--brand-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#2f58ab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c"&gt;/* "a bit darker" — picked by eye, once */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#244289&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c"&gt;/* "darker still" — same deal */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#6e92e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c"&gt;/* "a bit lighter" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-tint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;111&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;214&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c"&gt;/* "faint background wash" */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those four extra values is somebody's good-faith attempt, at some point in the past, to eyeball a lighter or darker or more-transparent version of &lt;code&gt;--brand&lt;/code&gt;. They're not wrong on day one. The problem shows up the day &lt;code&gt;--brand&lt;/code&gt; changes and the other four don't — because CSS has no idea they were ever related. To the engine, &lt;code&gt;--brand-hover&lt;/code&gt; isn't "darker &lt;code&gt;--brand&lt;/code&gt;." It's just a string that starts with &lt;code&gt;#2f58ab&lt;/code&gt;, full stop.&lt;/p&gt;

&lt;p&gt;The "obvious" fix is discipline: a comment reminding whoever edits &lt;code&gt;--brand&lt;/code&gt; to also update the other four, or a design-tokens build step that regenerates them from a single source before CSS ever sees them. Both work, right up until the person making a one-line brand tweak six months from now doesn't know the build step exists, or skims past the comment. Discipline that depends on nobody being in a hurry isn't a fix — it's a bet, and the rebrand PR is the bet losing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The variables were never the problem — storing the &lt;em&gt;relationship&lt;/em&gt; was
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that matters: &lt;code&gt;--brand-hover&lt;/code&gt; was never really an independent color. It was always a &lt;em&gt;function of&lt;/em&gt; &lt;code&gt;--brand&lt;/code&gt; — "darker, same hue." &lt;code&gt;--brand-tint&lt;/code&gt; was always "the same color, 8% opaque." The relationship was real and well understood by whoever first picked those hex codes. What got lost was that the relationship lived in a person's head for thirty seconds and then evaporated, leaving only its one-time output behind, frozen in a stylesheet forever.&lt;/p&gt;

&lt;p&gt;What you actually want is for CSS to store the &lt;em&gt;relationship&lt;/em&gt;, not just a snapshot of what it once produced — and that's a genuinely new category of thing for CSS to express. For most of CSS's life, a color value was a dead end: &lt;code&gt;#3b6fd6&lt;/code&gt; tells the renderer what to paint, but nothing about where it came from or how to get a cousin of it. &lt;code&gt;color-mix()&lt;/code&gt; loosened that slightly — you can blend two colors — but blending two colors isn't the shape of this problem. You don't have two brand colors to mix; you have &lt;strong&gt;one&lt;/strong&gt; color, and you want its lightness turned down a notch while everything else about it stays put.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you keep reading: what would you even call the CSS feature that lets you say "same color, just darker"?&lt;/strong&gt; It's a fair question, because until relatively recently there wasn't a clean answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relative color syntax: read a color's own channels back out
&lt;/h2&gt;

&lt;p&gt;Relative color syntax lets a color function take another color as input and expose &lt;em&gt;that color's own channels&lt;/em&gt; as variables you can do math on, right there in the function call:&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="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#3b6fd6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c"&gt;/* "same hue and chroma as --brand, just darker" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--brand-active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c"&gt;/* "same color, lighter, for a border" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c"&gt;/* "same color, 8% opaque" */&lt;/span&gt;
  &lt;span class="py"&gt;--brand-tint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;8%&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;Read the &lt;code&gt;oklch(from var(--brand) ...)&lt;/code&gt; part literally: "take &lt;code&gt;--brand&lt;/code&gt;, convert it to OKLCH, and give me three variables back — &lt;code&gt;l&lt;/code&gt; for lightness, &lt;code&gt;c&lt;/code&gt; for chroma, &lt;code&gt;h&lt;/code&gt; for hue." Whatever you write after that is just CSS math on those three numbers (plus an optional alpha after the slash). &lt;code&gt;calc(l - 0.12)&lt;/code&gt; means "this color's own lightness, minus 0.12" — not a hardcoded lightness value, a relationship to whatever &lt;code&gt;--brand&lt;/code&gt; happens to be &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Change &lt;code&gt;--brand&lt;/code&gt; to a rebrand's new blue, or swap it entirely for a dark-mode theme, and every line below it recomputes. There's no second hex code to forget, because there is no second hex code — &lt;code&gt;--brand-hover&lt;/code&gt; isn't stored anywhere; it's derived, every time the browser needs it, from the one variable a human actually edits.&lt;/p&gt;

&lt;p&gt;This isn't OKLCH-only, either. &lt;code&gt;rgb()&lt;/code&gt;, &lt;code&gt;hsl()&lt;/code&gt;, and &lt;code&gt;color()&lt;/code&gt; all support the same &lt;code&gt;from&lt;/code&gt; syntax, exposing whichever channels that color space naturally has:&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;/* rgb()'s own channels: r, g, b (0–255), alpha */&lt;/span&gt;
&lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;15%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* hsl()'s own channels: h, s, l, alpha — s and l come back as plain
   numbers (0-100), not percentages, so the math below uses 10, not 10% */&lt;/span&gt;
&lt;span class="nc"&gt;.link&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="nf"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;10&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;OKLCH is usually the better pick for lightness math specifically, because it's designed so that "lightness" tracks &lt;em&gt;perceived&lt;/em&gt; brightness consistently across hues — nudging &lt;code&gt;l&lt;/code&gt; down by the same amount darkens a yellow and a blue by roughly the same perceived amount. Nudge &lt;code&gt;hsl()&lt;/code&gt;'s &lt;code&gt;l&lt;/code&gt; channel the same way and the result can feel inconsistent hue to hue, because HSL's lightness was never built to model human perception in the first place.&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-relative-color-syntax/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 one sharp edge: clamp your math
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;calc(l - 0.12)&lt;/code&gt; is honest math, which means it'll happily produce a number outside the range that makes sense. Pick a brand color that's already quite dark, subtract 0.20 from its lightness, and &lt;code&gt;l&lt;/code&gt; can go negative — which isn't a color error so much as a "this doesn't mean anything" result, rendered as whatever the browser decides black looks like.&lt;/p&gt;

&lt;p&gt;The fix is the same &lt;code&gt;clamp()&lt;/code&gt; you'd reach for anywhere else in CSS, wrapped around the channel math:&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="nt"&gt;--brand-hover&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;oklch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--brand&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;clamp&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;calc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;l&lt;/span&gt; &lt;span class="nt"&gt;-&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;12&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;c&lt;/span&gt; &lt;span class="nt"&gt;h&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one change makes the derivation safe for &lt;em&gt;any&lt;/em&gt; input color, including ones your design system doesn't control yet — a user-customizable accent color, say, or a brand color read from a CMS. Skip the clamp and the formula only works for colors in roughly the lightness range you tested it against, which defeats a good chunk of the point: a derivation that only works for some inputs is just a more elaborate hardcoded value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this safe to ship, or is it another flag-gated preview?
&lt;/h2&gt;

&lt;p&gt;Safe to ship — but the rollout order isn't what you'd guess. Safari 16.4 (March 2023) was the earliest browser with &lt;em&gt;something&lt;/em&gt;, but it was a partial pass at an older draft of the spec with real parsing bugs; that implementation was later replaced, not extended. Chrome and Firefox did the work of actually matching the final spec through 2024 (Firefox in Firefox 128, July 2024), and Safari didn't catch up — bug fixes included — until Safari 18 in September 2024. So for the exact forms used above, Safari was last to ship the behavior this post describes, not first. Either way, that was over two years ago: by now every major evergreen browser has unprefixed, unflagged, spec-correct support, and there's no realistic "but what about X" objection left to raise for a typical modern browser matrix. If your project still has to support browsers from before that window, the fallback is simple and honest: keep writing literal hex variants for those browsers, and layer the relative syntax on top for everyone else — it degrades to "the hardcoded value you already had," not to a broken page.&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-relative-color-syntax/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 bug wasn't really "someone forgot to update a hex code." It was that the stylesheet never had anywhere to &lt;em&gt;put&lt;/em&gt; the fact that &lt;code&gt;--brand-hover&lt;/code&gt; meant "darker &lt;code&gt;--brand&lt;/code&gt;" — so that knowledge lived in a person's memory instead, with exactly the shelf life you'd expect. Relative color syntax isn't a shorter way to write the same five hex codes. It's a way to stop writing four of them at all.&lt;/p&gt;

&lt;p&gt;Where does your own color system still keep that kind of relationship in a comment instead of in the CSS itself?&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/the-css-property-that-fixes-dark-modes-ugly-white-boxes-58hc"&gt;The CSS Property That Fixes Dark Mode's Ugly White Boxes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/one-css-property-replaces-your-checkbox-hack-4jhk"&gt;One CSS Property Replaces Your Checkbox Hack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/parsajiravand/you-reach-for-sass-to-mix-colors-color-mix-does-it-natively-17p1"&gt;You reach for Sass to mix colors. &lt;code&gt;color-mix()&lt;/code&gt; does it natively.&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>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
