<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Parsa Jiravand</title>
    <description>The latest articles on DEV Community by Parsa Jiravand (@parsajiravand).</description>
    <link>https://dev.to/parsajiravand</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3831018%2Ff09b70fc-3b0d-4ce2-bb7e-d78ee6f7d701.jpg</url>
      <title>DEV Community: Parsa Jiravand</title>
      <link>https://dev.to/parsajiravand</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/parsajiravand"/>
    <language>en</language>
    <item>
      <title>getCurrentPosition() Doesn't Just Check — It Prompts</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:26:10 +0000</pubDate>
      <link>https://dev.to/parsajiravand/getcurrentposition-doesnt-just-check-it-prompts-52ap</link>
      <guid>https://dev.to/parsajiravand/getcurrentposition-doesnt-just-check-it-prompts-52ap</guid>
      <description>&lt;p&gt;You're building a "find stores near you" banner. The right move, UX-wise, is to explain &lt;em&gt;why&lt;/em&gt; you want the visitor's location before the browser's blunt little system dialog interrupts them — but only if that dialog hasn't already fired. If they already said yes, just get the location and skip the banner. If they already said no, don't ask again; show a manual city picker instead.&lt;/p&gt;

&lt;p&gt;So before you render anything, you write a quick check: has this visitor already decided?&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;showStoresNearMe&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="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;showManualPicker&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;You test it in a fresh incognito window. The system permission dialog pops up immediately — the exact one you were trying to gate behind your own friendly copy. Not after your banner. Instead of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guess before you scroll:&lt;/strong&gt; the code above isn't buggy. It's doing exactly what &lt;code&gt;getCurrentPosition&lt;/code&gt; is supposed to do. The bug is in the assumption that calling it was a way to &lt;em&gt;check&lt;/em&gt; something.&lt;/p&gt;

&lt;h2&gt;
  
  
  There's no "just checking" with the Geolocation API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getCurrentPosition&lt;/code&gt; doesn't have a peek mode. Every call is a real request for the visitor's location, and the browser treats it that way: if the permission state for this origin is still undecided, it shows the system prompt right then, synchronously with your call. If the visitor already granted permission, it skips straight to fetching a position. If they already denied it, your error callback fires with &lt;code&gt;PERMISSION_DENIED&lt;/code&gt; — no dialog, but you only find that out &lt;em&gt;after&lt;/em&gt; asking.&lt;/p&gt;

&lt;p&gt;That last case is the closest thing to a "check" the API gives you, and it's a bad one: to learn the state, you have to make the same call you'd make to actually use the feature, and if the state happens to be "undecided," making that call &lt;em&gt;is&lt;/em&gt; deciding it — badly, with zero context, at a moment you didn't choose.&lt;/p&gt;

&lt;p&gt;There's a reason this feels like a design gap and not a skill issue: the thing you actually want to read — "has this origin already been asked, and what did the visitor say?" — isn't geolocation-specific data at all. It's account-keeping the browser does for every permission-gated feature, and the Geolocation API was never built to expose it. You were reaching for a getter through a function whose entire job is to &lt;em&gt;act&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API that only reads
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API" rel="noopener noreferrer"&gt;Permissions API&lt;/a&gt; is that getter. &lt;code&gt;navigator.permissions.query()&lt;/code&gt; asks the browser "what's the current state of this permission for this origin?" and resolves with the answer — without ever triggering the system dialog itself, regardless of what the answer turns out to be:&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;geolocation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "granted" | "denied" | "prompt"&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;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showStoresNearMe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// safe to call getCurrentPosition silently&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;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prompt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showWhyWeAskBanner&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// explain first, THEN call getCurrentPosition&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showManualPicker&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// already denied — don't ask again&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;prompt&lt;/code&gt; means "undecided" — the browser hasn't shown this visitor a dialog for this permission on this origin yet. That's your one and only safe moment to show your own explanation first. Miss it — by calling the real API to "check" — and the browser's dialog wins the moment instead of yours.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;status&lt;/code&gt; object isn't a one-shot snapshot, either. It's a live handle you can subscribe to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;status&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;change&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;permission is now:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&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 the visitor opens your site's permission settings via the browser's UI (the little padlock icon) and flips geolocation while your tab is still open, &lt;code&gt;change&lt;/code&gt; fires and &lt;code&gt;status.state&lt;/code&gt; updates — no reload, no re-query. That's useful for exactly the kind of banner you're building: if someone denies it mid-session, you can swap the banner for the manual picker on the spot instead of waiting for their next visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: not every browser recognizes every name
&lt;/h2&gt;

&lt;p&gt;The Permissions API supports more than geolocation — &lt;code&gt;notifications&lt;/code&gt;, &lt;code&gt;camera&lt;/code&gt;, &lt;code&gt;microphone&lt;/code&gt;, &lt;code&gt;persistent-storage&lt;/code&gt;, &lt;code&gt;clipboard-read&lt;/code&gt;, and others, each queried by its string name. But which names a given browser recognizes varies, and &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query" rel="noopener noreferrer"&gt;MDN is explicit&lt;/a&gt; about what happens when you ask for one it doesn't: the promise doesn't resolve to some "unknown" state — it &lt;strong&gt;rejects with a &lt;code&gt;TypeError&lt;/code&gt;&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&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="c1"&gt;// Some browsers don't recognize "camera" as a queryable name.&lt;/span&gt;
  &lt;span class="c1"&gt;// Fall back to your default UX rather than assuming a state.&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;couldn't read that permission here:&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;message&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;Skip the &lt;code&gt;try/catch&lt;/code&gt; and an unsupported name doesn't quietly do nothing — it throws an unhandled rejection in the middle of your permission logic. Given how much this varies by browser and by permission name, treat every &lt;code&gt;query()&lt;/code&gt; call as something that can fail, not just something that can resolve to three states.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;"Check first, then ask" is good instinct. The mistake is reaching for the feature's own API to do the checking — &lt;code&gt;getCurrentPosition&lt;/code&gt;, &lt;code&gt;Notification.requestPermission&lt;/code&gt;, &lt;code&gt;navigator.mediaDevices.getUserMedia&lt;/code&gt; — when each of those is built to &lt;em&gt;act&lt;/em&gt;, and for a permission still in &lt;code&gt;prompt&lt;/code&gt; state, acting and asking are the same button. The Permissions API is the one part of the platform whose entire job is to answer without acting. If you find yourself calling a feature just to see what it does before deciding whether to really call it, that's the tell: you wanted a read, and you reached for a write.&lt;/p&gt;

&lt;p&gt;Numbers and prose only get you so far here — the difference between "reading" and "doing" is more convincing when you can trigger both yourself and watch which one leaves a system dialog behind.&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/permissions-api-query-before-prompt/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/permissions-api-query-before-prompt/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;Go check the permission-gated features already in your app — location, notifications, the clipboard, the camera. If any of them decide whether to show your own explanation by calling the real API first, that's a prompt firing at a moment you didn't pick. What's the worst time your app's permission prompt has ever popped up on someone?&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>NestJS Guards: CanActivate, ExecutionContext &amp; Reflector</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:25:38 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nestjs-guards-canactivate-executioncontext-reflector-1onm</link>
      <guid>https://dev.to/parsajiravand/nestjs-guards-canactivate-executioncontext-reflector-1onm</guid>
      <description>&lt;p&gt;A code review flags a "protected" admin endpoint. The controller has &lt;code&gt;@UseGuards(RolesGuard)&lt;/code&gt; on the class, &lt;code&gt;RolesGuard&lt;/code&gt; injects a &lt;code&gt;UserService&lt;/code&gt; to check role hierarchy, and it's registered application-wide with &lt;code&gt;app.useGlobalGuards(new RolesGuard(reflector, userService))&lt;/code&gt; in &lt;code&gt;main.ts&lt;/code&gt;. It looks careful. It is also broken: &lt;code&gt;userService&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; inside the guard, on every single request, and no test would have caught it because the app never crashed — it just let every request through the branch that assumes a user has no elevated role.&lt;/p&gt;

&lt;p&gt;Nothing here is a typo. The guard class is correct. The decorator is correct. The one thing wrong is &lt;em&gt;how&lt;/em&gt; the guard was registered — and until you know what a guard actually is to Nest's container, that line looks completely reasonable.&lt;/p&gt;

&lt;p&gt;This is written against &lt;strong&gt;NestJS 12.0.x&lt;/strong&gt; (verified September 2026, current &lt;code&gt;@nestjs/core&lt;/code&gt; release, &lt;a href="https://github.com/nestjs/nest/releases" rel="noopener noreferrer"&gt;v12.0.4&lt;/a&gt;). The &lt;code&gt;CanActivate&lt;/code&gt; interface, &lt;code&gt;ExecutionContext&lt;/code&gt;, and &lt;code&gt;Reflector&lt;/code&gt; covered here have been stable since well before v9 and are untouched by v12's ESM and Standard Schema changes — nothing in this article is version-fragile.&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 a guard actually is — a DI-instantiated class, not a function you happen to call &lt;code&gt;canActivate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read and use &lt;code&gt;ExecutionContext&lt;/code&gt; to get at the request, the handler, and the controller class from inside a guard&lt;/li&gt;
&lt;li&gt;Build a &lt;code&gt;Reflector&lt;/code&gt;-backed &lt;code&gt;@Roles()&lt;/code&gt; decorator and understand exactly why the method's metadata overrides the class's&lt;/li&gt;
&lt;li&gt;Register a global guard the &lt;em&gt;right&lt;/em&gt; way (&lt;code&gt;APP_GUARD&lt;/code&gt;) so it keeps its dependency injection&lt;/li&gt;
&lt;li&gt;Predict, for any combination of global/controller/method guards, the exact order they run in and what "all must pass" means&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You've written a NestJS controller, used &lt;code&gt;@UseGuards()&lt;/code&gt; at least once, and you know what &lt;code&gt;@Injectable()&lt;/code&gt; does. If you haven't read the &lt;a href="https://dev.to/parsajiravand/nestjs-request-lifecycle-explained-with-cheat-sheet-227l"&gt;NestJS request lifecycle&lt;/a&gt; episode of this series, it's a useful map of where guards sit relative to middleware, interceptors, and pipes — but this article is self-contained.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: a guard that quietly can't do its job&lt;/li&gt;
&lt;li&gt;The mental model: a guard is a provider, not a function&lt;/li&gt;
&lt;li&gt;Stage 1: the smallest correct guard&lt;/li&gt;
&lt;li&gt;Stage 2: ExecutionContext, properly&lt;/li&gt;
&lt;li&gt;Stage 3: Reflector and a real &lt;code&gt;@Roles()&lt;/code&gt; decorator&lt;/li&gt;
&lt;li&gt;Stage 4: registering a global guard without losing DI&lt;/li&gt;
&lt;li&gt;Stage 5: composing guards — order and "all must pass"&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a guard that quietly can't do its job
&lt;/h2&gt;

&lt;p&gt;Here's the guard from the intro, in full:&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&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;class&lt;/span&gt; &lt;span class="nc"&gt;RolesGuard&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;reflector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Reflector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// meant to check role hierarchy&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;required&lt;/span&gt; &lt;span class="o"&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;reflector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;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;roles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHandler&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// userService is undefined here — this branch always throws or always no-ops&lt;/span&gt;
    &lt;span class="k"&gt;return&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;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAnyRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;required&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;And here's how it was wired up, because "global" sounded like the right word for "runs on every route":&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 — looks reasonable, is quietly broken&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&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;NestFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;);&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;useGlobalGuards&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;RolesGuard&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;Reflector&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="cm"&gt;/* userService? */&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;app.useGlobalGuards()&lt;/code&gt; takes an &lt;em&gt;instance&lt;/em&gt;. Nothing about that instance goes through Nest's dependency injection — you constructed it yourself, with &lt;code&gt;new&lt;/code&gt;, outside any module, before the application's providers even exist to hand it a real &lt;code&gt;UserService&lt;/code&gt;. Nest will happily call &lt;code&gt;canActivate()&lt;/code&gt; on this object for every request. It just can't give it the dependency the guard was written to use.&lt;/p&gt;

&lt;p&gt;The fix isn't a different guard. It's a different &lt;em&gt;registration&lt;/em&gt;, covered in Stage 4. But to see why that fix works, you need the actual mental model of what a guard is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: a guard is a provider, not a function
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; a NestJS guard is an ordinary DI provider — the same kind of class as a service — that additionally implements one method, &lt;code&gt;canActivate(context: ExecutionContext)&lt;/code&gt;, which Nest calls immediately before it would otherwise invoke your route handler. If &lt;code&gt;canActivate&lt;/code&gt; resolves to &lt;code&gt;true&lt;/code&gt;, the request keeps moving toward interceptors, pipes, and the handler. If it resolves to &lt;code&gt;false&lt;/code&gt;, or throws, the handler never runs and Nest hands the request straight to the exception-filter layer.&lt;/p&gt;

&lt;p&gt;Three consequences fall out of that one sentence, and they're the three things the intro's bug got wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A guard only gets real dependency injection if Nest constructs it.&lt;/strong&gt; &lt;code&gt;@UseGuards(RolesGuard)&lt;/code&gt; (passing the &lt;em&gt;class&lt;/em&gt;) lets Nest instantiate it through the container, resolving its constructor arguments normally. &lt;code&gt;new RolesGuard(...)&lt;/code&gt; (passing an &lt;em&gt;instance&lt;/em&gt; you built) does not — you're on your own for every dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guard doesn't see "a request" in the abstract — it sees an &lt;code&gt;ExecutionContext&lt;/code&gt;.&lt;/strong&gt; That's a wrapper Nest builds fresh for every incoming call, giving the guard a uniform way to reach the underlying request &lt;em&gt;and&lt;/em&gt; to ask "what handler and class is Nest about to invoke?" — which is exactly what a guard needs to look up route-specific metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guard's decision is binary, not additive.&lt;/strong&gt; It either lets a request through or it doesn't. Anything more nuanced than "yes/no" — attaching data, transforming the body — is an interceptor's or a pipe's job, not a guard's.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Stage 1: the smallest correct guard
&lt;/h2&gt;

&lt;p&gt;The simplest guard that compiles and does something real:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Injectable&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;@nestjs/common&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="nd"&gt;Injectable&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;class&lt;/span&gt; &lt;span class="nc"&gt;AuthGuard&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&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;Apply it to one route:&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="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;canActivate&lt;/code&gt; can return &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;Promise&amp;lt;boolean&amp;gt;&lt;/code&gt;, or &lt;code&gt;Observable&amp;lt;boolean&amp;gt;&lt;/code&gt; — Nest awaits or subscribes to whichever you give it. A guard that calls a database or an external identity provider to check a session is completely normal; just make it &lt;code&gt;async canActivate(...): Promise&amp;lt;boolean&amp;gt;&lt;/code&gt; and Nest will wait for it before deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: ExecutionContext, properly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ExecutionContext&lt;/code&gt; is the single argument every guard, interceptor, and exception filter receives, and it answers two different questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What kind of call is this, and what's the underlying request object?"&lt;/strong&gt; — via &lt;code&gt;switchToHttp()&lt;/code&gt;, &lt;code&gt;switchToRpc()&lt;/code&gt;, or &lt;code&gt;switchToWs()&lt;/code&gt;. Most guards only ever call &lt;code&gt;context.switchToHttp().getRequest()&lt;/code&gt;, but the same guard class &lt;em&gt;can&lt;/em&gt; run in front of a WebSocket gateway or a microservice handler if you check &lt;code&gt;context.getType()&lt;/code&gt; first and branch — that's what makes &lt;code&gt;ExecutionContext&lt;/code&gt; a context, not just an HTTP request wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Which handler and which class is Nest about to call?"&lt;/strong&gt; — via &lt;code&gt;context.getHandler()&lt;/code&gt; (the specific route method, as a function reference) and &lt;code&gt;context.getClass()&lt;/code&gt; (the controller class). This half is what makes metadata-driven guards possible, because it's the &lt;em&gt;only&lt;/em&gt; way a guard can ask "does this specific route carry a &lt;code&gt;@Roles(...)&lt;/code&gt; decorator?" — the guard runs once per registration, but &lt;code&gt;getHandler()&lt;/code&gt;/&lt;code&gt;getClass()&lt;/code&gt; tell it which route it's currently deciding for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;handlerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHandler&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// e.g. "getProfile"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controllerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClass&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// e.g. "UsersController"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;getHandler()&lt;/code&gt; and &lt;code&gt;getClass()&lt;/code&gt; return the raw function/class references, not strings — they exist so you can hand them to &lt;code&gt;Reflector&lt;/code&gt;, which looks up metadata &lt;em&gt;attached to those exact references&lt;/em&gt;. That's the bridge to Stage 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: Reflector and a real &lt;code&gt;@Roles()&lt;/code&gt; decorator
&lt;/h2&gt;

&lt;p&gt;Hardcoding a role check per route doesn't scale, and neither does branching on &lt;code&gt;handler.name&lt;/code&gt; — a rename breaks it silently. The idiomatic pattern is a custom decorator that attaches metadata, and a &lt;code&gt;Reflector&lt;/code&gt; that reads it back inside a guard.&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;// roles.decorator.ts&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;SetMetadata&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;@nestjs/common&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;ROLES_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roles&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;Roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;roles&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="nc"&gt;SetMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLES_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;roles&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;// roles.guard.ts&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;CanActivate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Injectable&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;@nestjs/common&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;Reflector&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;@nestjs/core&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;ROLES_KEY&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;./roles.decorator&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="nd"&gt;Injectable&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;class&lt;/span&gt; &lt;span class="nc"&gt;RolesGuard&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;reflector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Reflector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;required&lt;/span&gt; &lt;span class="o"&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;reflector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getAllAndOverride&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLES_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHandler&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClass&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;// no @Roles() = open route&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;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&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;required&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;role&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;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&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 typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&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="nd"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// class-level default&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrdersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* requires "editor" — inherits the class default */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:id&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="nd"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// method-level override&lt;/span&gt;
  &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* requires "admin" — this wins over the class's "editor" */&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;getAllAndOverride(key, [handler, class])&lt;/code&gt; checks the handler first, then the class, and returns the &lt;strong&gt;first one it finds&lt;/strong&gt; — it does not merge arrays. That order in the array is why a method-level &lt;code&gt;@Roles("admin")&lt;/code&gt; completely replaces the class-level &lt;code&gt;@Roles("editor")&lt;/code&gt; rather than requiring both. If you actually want both handler and class metadata combined, &lt;code&gt;Reflector&lt;/code&gt; also has &lt;code&gt;getAllAndMerge()&lt;/code&gt;, which concatenates arrays instead of short-circuiting — reach for it explicitly when "either level can add a role" is the behavior you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: registering a global guard without losing DI
&lt;/h2&gt;

&lt;p&gt;Back to the intro's bug. &lt;code&gt;app.useGlobalGuards(new RolesGuard(...))&lt;/code&gt; builds the guard outside the container, so any constructor dependency has to be supplied by hand — which is exactly what went wrong. The fix is to register the guard as a provider, using the &lt;code&gt;APP_GUARD&lt;/code&gt; injection token from &lt;code&gt;@nestjs/core&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;APP_GUARD&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;@nestjs/core&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="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;RolesGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APP_GUARD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;useClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RolesGuard&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;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;RolesGuard&lt;/code&gt; is now a normal provider, Nest resolves its constructor the usual way — &lt;code&gt;Reflector&lt;/code&gt; (and, in the intro's case, a real &lt;code&gt;UserService&lt;/code&gt;) get injected correctly, module-scoped providers work, and &lt;code&gt;Test.createTestingModule&lt;/code&gt; can &lt;code&gt;overrideProvider(RolesGuard)&lt;/code&gt; in tests the same way it overrides any other dependency, as covered in this series' &lt;a href="https://dev.to/parsajiravand/nestjs-testing-module-provider-overrides-with-cheat-sheet-1o79"&gt;testing module episode&lt;/a&gt;. None of that is available to a guard built with &lt;code&gt;new&lt;/code&gt; in &lt;code&gt;main.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;app.useGlobalGuards()&lt;/code&gt; still exists and still works for a guard with zero dependencies — it's not deprecated. The rule is narrower and easy to remember: the moment a guard's constructor needs anything Nest would normally inject, register it through &lt;code&gt;APP_GUARD&lt;/code&gt;, not &lt;code&gt;useGlobalGuards()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 5: composing guards — order and "all must pass"
&lt;/h2&gt;

&lt;p&gt;A single request can pass through guards registered at three different scopes at once: global (&lt;code&gt;APP_GUARD&lt;/code&gt;, or &lt;code&gt;useGlobalGuards()&lt;/code&gt;), controller (&lt;code&gt;@UseGuards()&lt;/code&gt; on the class), and method (&lt;code&gt;@UseGuards()&lt;/code&gt; on the handler). Nest runs them in that exact order — global, then controller, then method — and within one &lt;code&gt;@UseGuards(A, B)&lt;/code&gt; call, in the order listed.&lt;/p&gt;

&lt;p&gt;This composition is a logical &lt;strong&gt;AND&lt;/strong&gt;, not a fallback chain: every guard in the sequence must return (or resolve to) &lt;code&gt;true&lt;/code&gt;, or the request is rejected at the first one that doesn't. There's no "guard B can override guard A's denial" — a single &lt;code&gt;false&lt;/code&gt; anywhere in the chain ends the request immediately, and every guard after it, plus every interceptor and pipe, is skipped entirely.&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ThrottleGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RolesGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// ThrottleGuard runs first&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&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;class&lt;/span&gt; &lt;span class="nc"&gt;OrdersController&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a global &lt;code&gt;AuthGuard&lt;/code&gt; is also registered via &lt;code&gt;APP_GUARD&lt;/code&gt;, the real order for any route on this controller is: &lt;code&gt;AuthGuard&lt;/code&gt; → &lt;code&gt;ThrottleGuard&lt;/code&gt; → &lt;code&gt;RolesGuard&lt;/code&gt; → (method-level guards, if any) → interceptors → pipes → the handler.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A guard that throws vs. a guard that returns &lt;code&gt;false&lt;/code&gt;.&lt;/strong&gt; Returning &lt;code&gt;false&lt;/code&gt; produces a generic &lt;code&gt;403 Forbidden&lt;/code&gt;. Throwing a specific exception — &lt;code&gt;throw new UnauthorizedException("Session expired")&lt;/code&gt; — gives the client (and your logs) a far more useful signal, and is the idiomatic choice for anything beyond "just deny it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Reflector&lt;/code&gt; needs the exact same metadata key everywhere.&lt;/strong&gt; &lt;code&gt;SetMetadata(ROLES_KEY, ...)&lt;/code&gt; and &lt;code&gt;reflector.getAllAndOverride(ROLES_KEY, ...)&lt;/code&gt; must use the identical string (or, better, the same exported constant). A typo in one spot means the guard silently sees &lt;code&gt;undefined&lt;/code&gt; and treats the route as unrestricted — this fails open, which is the worst direction for an auth check to fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getAllAndOverride&lt;/code&gt; vs &lt;code&gt;getAllAndMerge&lt;/code&gt;.&lt;/strong&gt; Covered in Stage 3, but worth repeating because it's the single most common &lt;code&gt;Reflector&lt;/code&gt; mistake: reach for &lt;code&gt;getAllAndOverride&lt;/code&gt; when a method should be able to fully replace a class default, and &lt;code&gt;getAllAndMerge&lt;/code&gt; when both levels should contribute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket and microservice guards need a type check.&lt;/strong&gt; &lt;code&gt;context.switchToHttp()&lt;/code&gt; throws if the current call isn't actually HTTP. A guard meant to run across transports should branch on &lt;code&gt;context.getType()&lt;/code&gt; (&lt;code&gt;"http"&lt;/code&gt;, &lt;code&gt;"ws"&lt;/code&gt;, &lt;code&gt;"rpc"&lt;/code&gt;) before picking which &lt;code&gt;switchTo*()&lt;/code&gt; to call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A denied guard skips the "before" half of interceptors too.&lt;/strong&gt; Interceptors run after guards, so a rejected request never reaches even the setup code in an interceptor — only the exception-filter layer sees it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep guards to yes/no authorization decisions.&lt;/strong&gt; If you find yourself mutating the request object inside a guard, that logic usually belongs in middleware (before routing) or an interceptor (after the handler is chosen) instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer metadata-driven guards over hardcoded checks.&lt;/strong&gt; A &lt;code&gt;@Roles()&lt;/code&gt;/&lt;code&gt;Reflector&lt;/code&gt; pair scales to new routes with zero changes to the guard itself; an &lt;code&gt;if (handler.name === "remove")&lt;/code&gt; branch doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register anything with a dependency via &lt;code&gt;APP_GUARD&lt;/code&gt;, never &lt;code&gt;new Guard()&lt;/code&gt;.&lt;/strong&gt; It's the difference between a guard that's testable and overridable, and one that silently can't be either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed, not open.&lt;/strong&gt; If a metadata lookup comes back &lt;code&gt;undefined&lt;/code&gt; because of a wiring mistake, decide what that &lt;em&gt;should&lt;/em&gt; mean deliberately (usually: deny), rather than letting &lt;code&gt;!required&lt;/code&gt; accidentally mean "allow everyone."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One guard, one concern.&lt;/strong&gt; A &lt;code&gt;ThrottleGuard&lt;/code&gt; and a &lt;code&gt;RolesGuard&lt;/code&gt; composed via &lt;code&gt;@UseGuards(ThrottleGuard, RolesGuard)&lt;/code&gt; are each easier to test and reuse than one guard doing both jobs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Do guards run before or after middleware?
&lt;/h3&gt;

&lt;p&gt;After. Middleware runs first and doesn't know which controller or handler will end up serving the request; guards run once routing has resolved to a specific handler, which is what lets a guard call &lt;code&gt;context.getHandler()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a guard inject a service?
&lt;/h3&gt;

&lt;p&gt;Yes, as long as Nest constructs the guard — via &lt;code&gt;@UseGuards(SomeGuard)&lt;/code&gt; (the class) or an &lt;code&gt;APP_GUARD&lt;/code&gt; provider. A guard instance you build yourself with &lt;code&gt;new&lt;/code&gt; gets none of Nest's dependency injection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use &lt;code&gt;@UseGuards()&lt;/code&gt; more than once on the same controller?
&lt;/h3&gt;

&lt;p&gt;Yes — &lt;code&gt;@UseGuards(A, B, C)&lt;/code&gt; runs them in that order, and you can also stack a class-level &lt;code&gt;@UseGuards()&lt;/code&gt; with a method-level one; both apply, in the global → controller → method order described in Stage 5.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does a guard returning &lt;code&gt;false&lt;/code&gt; actually send to the client?
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;403 Forbidden&lt;/code&gt; by default, handled by Nest's built-in exception layer. Throw a specific &lt;code&gt;HttpException&lt;/code&gt; subclass from inside the guard if you need a different status code or a custom error body.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;Reflector&lt;/code&gt; only for guards?
&lt;/h3&gt;

&lt;p&gt;No — interceptors and custom decorators use it too, for the same reason: reading metadata attached to a handler or class via &lt;code&gt;SetMetadata&lt;/code&gt;. Guards are just its most common consumer, because "does this route require X" is the canonical authorization question.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A guard is a provider that implements CanActivate.&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&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;class&lt;/span&gt; &lt;span class="nc"&gt;MyGuard&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;reflector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Reflector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="c1"&gt;// real DI, if registered correctly&lt;/span&gt;

  &lt;span class="nf"&gt;canActivate&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;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&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="nx"&gt;boolean&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;req&lt;/span&gt; &lt;span class="o"&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;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// the underlying request&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&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;getHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                  &lt;span class="c1"&gt;// the exact route method&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClass&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                 &lt;span class="c1"&gt;// the controller class&lt;/span&gt;
    &lt;span class="k"&gt;return&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;// or false, or throw a specific HttpException&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Metadata decorator + read-back&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;ROLES_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roles&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;Roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;roles&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="nc"&gt;SetMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLES_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// method metadata wins over class metadata:&lt;/span&gt;
&lt;span class="nx"&gt;reflector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getAllAndOverride&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLES_KEY&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;getHandler&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;getClass&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
&lt;span class="c1"&gt;// both levels contribute instead:&lt;/span&gt;
&lt;span class="nx"&gt;reflector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getAllAndMerge&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLES_KEY&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;getHandler&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;getClass&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;

&lt;span class="c1"&gt;// Registration — pick based on whether the guard has dependencies&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MyGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// per-route or per-controller, always through DI&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;useGlobalGuards&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;MyGuard&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// global, but NO dependency injection&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APP_GUARD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;useClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MyGuard&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// global, WITH dependency injection&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Registration&lt;/th&gt;
&lt;th&gt;Runs when&lt;/th&gt;
&lt;th&gt;Gets DI?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Method&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@UseGuards(G)&lt;/code&gt; on a handler&lt;/td&gt;
&lt;td&gt;Only that route&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@UseGuards(G)&lt;/code&gt; on a class&lt;/td&gt;
&lt;td&gt;Every route in that controller&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global (correct)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APP_GUARD&lt;/code&gt; provider&lt;/td&gt;
&lt;td&gt;Every route in the app&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global (limited)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app.useGlobalGuards(new G())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every route in the app&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Execution order for one request: &lt;strong&gt;global → controller → method&lt;/strong&gt;, each one an AND — the first &lt;code&gt;false&lt;/code&gt; (or thrown exception) stops the chain immediately.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A guard is a DI provider with a &lt;code&gt;canActivate(context: ExecutionContext)&lt;/code&gt; method — not a bare function, and not free of the container's rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ExecutionContext&lt;/code&gt; gives a guard the underlying request (via &lt;code&gt;switchToHttp()&lt;/code&gt;/&lt;code&gt;switchToWs()&lt;/code&gt;/&lt;code&gt;switchToRpc()&lt;/code&gt;) and the exact handler/class Nest is about to invoke (via &lt;code&gt;getHandler()&lt;/code&gt;/&lt;code&gt;getClass()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Reflector.getAllAndOverride()&lt;/code&gt; reads metadata attached with a custom decorator, checking the method before the class, and returns the first match — use &lt;code&gt;getAllAndMerge()&lt;/code&gt; when you want both to contribute instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.useGlobalGuards(new G())&lt;/code&gt; skips dependency injection entirely; use the &lt;code&gt;APP_GUARD&lt;/code&gt; provider token for any global guard with constructor dependencies.&lt;/li&gt;
&lt;li&gt;Guards compose as global → controller → method, and it's a strict AND: any single &lt;code&gt;false&lt;/code&gt; or thrown exception stops the request before it reaches the next guard, any interceptor, or the handler.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The guard from the intro wasn't wrong about &lt;em&gt;what&lt;/em&gt; it wanted to check — it was wrong about &lt;em&gt;how it was born&lt;/em&gt;. &lt;code&gt;new RolesGuard(...)&lt;/code&gt; and &lt;code&gt;{ provide: APP_GUARD, useClass: RolesGuard }&lt;/code&gt; compile to the same class doing the same check, and only one of them lets Nest's container do its job. That's the whole lesson of guards: they look like plain functions with a &lt;code&gt;canActivate&lt;/code&gt; name, but every guarantee they can offer — real dependencies, testability, metadata lookups through &lt;code&gt;ExecutionContext&lt;/code&gt; — depends on Nest actually building them. Next time a "protected" route turns out not to be, check the registration line before you touch the guard's logic.&lt;/p&gt;

&lt;p&gt;What's the strangest guard bug you've chased down — a missing dependency, a metadata key typo, or something else? 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-guards-canactivate-reflector/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-guards-canactivate-reflector/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>nestjs</category>
      <category>node</category>
      <category>typescript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>localStorage Isn't Free — It's Blocking Your Main Thread</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:25:07 +0000</pubDate>
      <link>https://dev.to/parsajiravand/localstorage-isnt-free-its-blocking-your-main-thread-nmn</link>
      <guid>https://dev.to/parsajiravand/localstorage-isnt-free-its-blocking-your-main-thread-nmn</guid>
      <description>&lt;p&gt;You add autosave to a text editor. Every few seconds, whatever's in the textarea gets written to &lt;code&gt;localStorage&lt;/code&gt; so a refresh — or a crashed tab — doesn't cost the user their draft. You test it with a paragraph. Smooth. Ship it.&lt;/p&gt;

&lt;p&gt;Then someone pastes in a 6,000-word draft, and every autosave tick makes the whole page hitch for a beat — the cursor stalls, the animation in the sidebar stutters, keystrokes queue up and land late. Nothing crashed. No error. The console is clean. It just... pauses. Repeatedly. Forever, as long as the draft stays that big.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guess before you scroll:&lt;/strong&gt; it's not a bug in your debounce logic. The debounce is fine. The freeze is coming from a function you'd swear is instant, because it always has been — right up until the argument got large.&lt;/p&gt;

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

&lt;p&gt;Your first move is reasonable: you're calling &lt;code&gt;localStorage.setItem&lt;/code&gt; too often, so throttle 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;saveDraft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;span class="na"&gt;savedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;textarea&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="nf"&gt;saveDraft&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Autosaves now fire every two seconds instead of every keystroke. On a small draft, the stutter is gone — but that's because small drafts were never the problem. Paste the same 6,000-word draft back in and the page still hitches, just less often. Every two seconds instead of every keystroke, but each hitch is exactly as long as before.&lt;/p&gt;

&lt;p&gt;That's the tell. Debouncing controls &lt;em&gt;frequency&lt;/em&gt;. It does nothing to the &lt;em&gt;duration&lt;/em&gt; of a single call. If one call to &lt;code&gt;setItem&lt;/code&gt; blocks for 40ms, calling it less often gives you fewer 40ms freezes — not shorter ones.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; is a synchronous API. Not "usually fast" — synchronously specified. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" rel="noopener noreferrer"&gt;MDN is direct about it&lt;/a&gt;: every &lt;code&gt;getItem&lt;/code&gt; and &lt;code&gt;setItem&lt;/code&gt; call runs to completion on the thread that called it before anything else on that thread can run. No other event handler fires, no frame paints, no &lt;code&gt;requestAnimationFrame&lt;/code&gt; callback executes, until that one line returns.&lt;/p&gt;

&lt;p&gt;For a short string, "runs to completion" is sub-millisecond — you'll never see it. But your draft isn't a short string. Before it ever reaches &lt;code&gt;setItem&lt;/code&gt;, it goes through &lt;code&gt;JSON.stringify&lt;/code&gt; on a growing object, and then the storage write itself has to serialize and persist that string. Both steps happen on the same call, on the same thread, with the same guarantee: nothing else runs until it's done. A 5MB draft doesn't make &lt;code&gt;setItem&lt;/code&gt; async — it just makes the synchronous part take longer, and the page is unresponsive for exactly that long, every single time you call it.&lt;/p&gt;

&lt;p&gt;There's a second cost stacked on top: &lt;code&gt;localStorage&lt;/code&gt; only stores strings. Every save round-trips your data through &lt;code&gt;JSON.stringify&lt;/code&gt;, and every load round-trips it back through &lt;code&gt;JSON.parse&lt;/code&gt; — both synchronous, both scaling with payload size, both adding to the freeze. And you're working inside a ceiling most browsers put at roughly 5MB per origin for all of &lt;code&gt;localStorage&lt;/code&gt; combined, draft included. Get there and &lt;code&gt;setItem&lt;/code&gt; throws &lt;code&gt;QuotaExceededError&lt;/code&gt; instead of saving — which, for an autosave feature, is worse than a stutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: an API that doesn't block
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IndexedDB&lt;/code&gt; solves the actual problem, not a symptom of it. It's asynchronous from the ground up: you open a transaction, call &lt;code&gt;.put()&lt;/code&gt;, and get a &lt;code&gt;request&lt;/code&gt; object back immediately. The real work — serializing and persisting the value — happens off the synchronous call stack. Your code (and the browser's renderer) keeps running while it does.&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;openDraftsDb&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;indexedDB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onupgradeneeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onsuccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveDraft&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;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&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;openDraftsDb&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;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drafts&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;readwrite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;objectStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;put&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;span class="na"&gt;savedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// tx.oncomplete fires later — the call above already returned.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's missing: no &lt;code&gt;JSON.stringify&lt;/code&gt;. &lt;code&gt;IndexedDB&lt;/code&gt; uses the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm" rel="noopener noreferrer"&gt;structured clone algorithm&lt;/a&gt; instead of JSON serialization, so you can hand it the plain object — even &lt;code&gt;Date&lt;/code&gt;s, &lt;code&gt;Map&lt;/code&gt;s, &lt;code&gt;Blob&lt;/code&gt;s — and it stores the value directly. And the storage ceiling isn't a fixed 5MB; it's a share of whatever disk space the browser is willing to grant the origin, which you can check with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/estimate" rel="noopener noreferrer"&gt;&lt;code&gt;navigator.storage.estimate()&lt;/code&gt;&lt;/a&gt; and which is typically a large fraction of free disk space, not a hardcoded number.&lt;/p&gt;

&lt;p&gt;The trade you're making is real, not free: &lt;code&gt;IndexedDB&lt;/code&gt;'s API is callback- and event-based and noticeably more ceremony than &lt;code&gt;setItem(key, value)&lt;/code&gt;. For a handful of small, infrequent values — a theme preference, a feature flag — &lt;code&gt;localStorage&lt;/code&gt;'s synchronous simplicity is still the right call; the blocking never gets large enough to notice. The line to watch is payload size and write frequency, not "is this data important." A dark-mode toggle can live in &lt;code&gt;localStorage&lt;/code&gt; forever. A growing document, a cart with attachments, or anything you write on every keystroke should not.&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/indexeddb-localstorage-main-thread-blocking/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;Numbers in a paragraph don't land the way a stuttering animation does. The playground runs a &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop — a dot sliding back and forth, smooth as long as the main thread is free — next to two buttons that write the same payload two different ways. One call blocks the loop. The other doesn't. Watch the dot, not the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;"It's just a &lt;code&gt;localStorage.setItem&lt;/code&gt; call" is true and also exactly why it's easy to miss — the API looks identical whether the payload is 12 bytes or 12 megabytes, and only one of those is a problem. Debouncing a synchronous call gives you the same freeze, less often. Only an actually asynchronous API — &lt;code&gt;IndexedDB&lt;/code&gt;, here — gives you a shorter one.&lt;/p&gt;

&lt;p&gt;Go check what you're writing to &lt;code&gt;localStorage&lt;/code&gt; on a hot path — an editor, a form draft, anything with attachments or growing text. If the payload can grow past a few dozen KB, that's worth five minutes today. What's the biggest thing you've ever accidentally shoved into &lt;code&gt;localStorage&lt;/code&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/indexeddb-localstorage-main-thread-blocking/quiz" rel="noopener noreferrer"&gt;Take the 8-question quiz →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

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




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

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

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

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>JSON.stringify Is Quietly Deleting Your File Uploads</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:24:36 +0000</pubDate>
      <link>https://dev.to/parsajiravand/jsonstringify-is-quietly-deleting-your-file-uploads-55ka</link>
      <guid>https://dev.to/parsajiravand/jsonstringify-is-quietly-deleting-your-file-uploads-55ka</guid>
      <description>&lt;p&gt;QA signs off on the "edit profile" form. Change your display name, hit save, refresh — the new name is there. Ship it.&lt;/p&gt;

&lt;p&gt;Two days later: "I uploaded a new profile picture and it just... didn't change?" You check the network tab. The request fired. Status &lt;code&gt;200 OK&lt;/code&gt;. The server logged a successful update. No red text anywhere. By every signal your tools give you, this worked.&lt;/p&gt;

&lt;p&gt;It didn't. And the bug isn't in the upload handler, the server, or the image itself — it's already dead by the time the request leaves the browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guess before you scroll:&lt;/strong&gt; the file never made it into the request body in the first place. Not corrupted. Not rejected. Just — never there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code that looks completely fine
&lt;/h2&gt;

&lt;p&gt;Here's roughly what shipped:&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;form&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;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&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;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a pattern you've probably written yourself. &lt;code&gt;FormData&lt;/code&gt; reads the form's current values, &lt;code&gt;Object.fromEntries&lt;/code&gt; turns that into a plain object, &lt;code&gt;JSON.stringify&lt;/code&gt; turns &lt;em&gt;that&lt;/em&gt; into a request body. For a &lt;code&gt;name&lt;/code&gt; field or an &lt;code&gt;email&lt;/code&gt; field, it's exact and correct — the string goes in, the same string comes out the other end.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;input type="file" name="avatar"&amp;gt;&lt;/code&gt; in that same form goes in as a &lt;code&gt;File&lt;/code&gt; object. And that's where it quietly falls apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;Object.fromEntries&lt;/code&gt; actually hands you
&lt;/h2&gt;

&lt;p&gt;Log &lt;code&gt;data.avatar&lt;/code&gt; right after that first line and it looks completely normal:&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// File { name: "sunset.jpg", size: 482113, type: "image/jpeg" }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real file. Real name. Real size. Everything about it says "this is fine, carry on." So you do — straight into &lt;code&gt;JSON.stringify&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;avatar&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// "{}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That's the whole bug, in one line.&lt;/strong&gt; Not an error, not &lt;code&gt;undefined&lt;/code&gt;, not the string &lt;code&gt;"[object File]"&lt;/code&gt; — an empty JSON object, every single time, for every file, no matter how big or what type. &lt;code&gt;JSON.stringify&lt;/code&gt; walks an object's own enumerable properties to build its output. &lt;code&gt;File&lt;/code&gt; — and &lt;code&gt;Blob&lt;/code&gt;, which it extends — deliberately doesn't expose its data that way. &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt;, and &lt;code&gt;type&lt;/code&gt; are accessors defined on the prototype, not enumerable data sitting on the instance, and the actual bytes aren't reachable synchronously at all. &lt;code&gt;JSON.stringify&lt;/code&gt; finds nothing to walk and does exactly what the spec says: it writes out &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the request that leaves the browser looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alex Chen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"avatar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server receives valid JSON, updates the name, sees &lt;code&gt;avatar: {}&lt;/code&gt;, probably ignores a field it doesn't recognize the shape of — and returns &lt;code&gt;200 OK&lt;/code&gt;, because as far as it's concerned, nothing went wrong. Nothing &lt;em&gt;did&lt;/em&gt; go wrong, downstream of the browser. The bug already happened, silently, on your side of the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "fix" that makes it worse
&lt;/h2&gt;

&lt;p&gt;The instinct once you find this is: fine, get the file's actual bytes into the JSON some other way. &lt;code&gt;FileReader.readAsDataURL()&lt;/code&gt; will happily hand you a base64 string:&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;toBase64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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;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="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;reader&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;FileReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&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;avatar&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;toBase64&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;avatar&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// now it's a string!&lt;/span&gt;
&lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does work — the image genuinely round-trips now. But you've traded a silent bug for three quiet costs that show up later instead of immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base64 inflates the payload by roughly a third.&lt;/strong&gt; A 3 MB photo becomes a ~4 MB string, because base64 spends 4 characters to encode every 3 bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole file sits in memory twice&lt;/strong&gt; — once as the original &lt;code&gt;Blob&lt;/code&gt;, once as the decoded string — for as long as the request is in flight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You've reinvented &lt;code&gt;multipart/form-data&lt;/code&gt;&lt;/strong&gt;, badly, using text encoding for something the browser already ships a binary-safe way to send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that fails a test. It just makes uploads slower and heavier in a way nobody notices until someone tries to upload a 20 MB image from their phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix: stop converting to JSON
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FormData&lt;/code&gt; was never the problem. Converting it into something else was. &lt;code&gt;fetch&lt;/code&gt; accepts a &lt;code&gt;FormData&lt;/code&gt; object as a body directly:&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;form&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;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// don't unwrap it&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// no headers, no JSON.stringify&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice, both easy to get backwards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't set &lt;code&gt;Content-Type&lt;/code&gt; yourself.&lt;/strong&gt; &lt;code&gt;multipart/form-data&lt;/code&gt; requests need a &lt;code&gt;boundary&lt;/code&gt; value in the header to separate fields, and &lt;code&gt;fetch&lt;/code&gt; generates a fresh, unique one for you when it sees a &lt;code&gt;FormData&lt;/code&gt; body. Set the header manually and you'll ship it &lt;em&gt;without&lt;/em&gt; a boundary, which breaks the request in a way that's genuinely confusing to debug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file's actual bytes travel this time.&lt;/strong&gt; No encoding step, no size penalty, no extra copy in memory — the browser streams the binary data as part of the multipart body, the same mechanism a plain HTML form has used since the 90s.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one real trade-off: your server needs to parse &lt;code&gt;multipart/form-data&lt;/code&gt;, not &lt;code&gt;application/json&lt;/code&gt; — most frameworks have a one-line answer for this already (Express: &lt;code&gt;multer&lt;/code&gt;; Node's built-in &lt;code&gt;http&lt;/code&gt;: &lt;code&gt;formidable&lt;/code&gt;; plenty of others read it natively).&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing &lt;code&gt;Object.fromEntries&lt;/code&gt; was hiding
&lt;/h2&gt;

&lt;p&gt;There's a second gotcha in that first line of code, unrelated to files: &lt;code&gt;Object.fromEntries&lt;/code&gt; silently drops duplicate keys, keeping only the last one. Add a checkbox group like &lt;code&gt;&amp;lt;input type="checkbox" name="topics" value="css"&amp;gt;&lt;/code&gt; repeated three times, check two boxes, and &lt;code&gt;Object.fromEntries(new FormData(form)).topics&lt;/code&gt; gives you exactly one string — not the array you'd expect.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FormData&lt;/code&gt; itself never had this problem. &lt;code&gt;formData.getAll("topics")&lt;/code&gt; returns every checked value, in DOM order, from the start. It's specifically the trip through &lt;code&gt;Object.fromEntries&lt;/code&gt; that quietly collapses them — one more reason to hand &lt;code&gt;FormData&lt;/code&gt; to &lt;code&gt;fetch&lt;/code&gt; as-is instead of reshaping it 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/formdata-file-upload-json-stringify-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;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Any time you're about to call &lt;code&gt;JSON.stringify&lt;/code&gt; on something that came out of a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, stop for one second and ask what's actually in it. Text fields survive the round trip. Files and repeated-name fields don't — not with an error, just with data quietly missing from the request that already reported success.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FormData&lt;/code&gt; isn't a stepping stone to JSON. For anything with a file in it, it's the destination.&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/formdata-file-upload-json-stringify-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;p&gt;Go check your own upload forms — if you see &lt;code&gt;Object.fromEntries(new FormData(...))&lt;/code&gt; followed anywhere by &lt;code&gt;JSON.stringify&lt;/code&gt;, that's worth a five-minute look today. What's the quietest "it returned 200 but didn't actually work" bug you've shipped?&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>tutorial</category>
      <category>html</category>
    </item>
    <item>
      <title>The Background Task That Waited 40 Seconds for 'Idle'</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:24:05 +0000</pubDate>
      <link>https://dev.to/parsajiravand/the-background-task-that-waited-40-seconds-for-idle-5736</link>
      <guid>https://dev.to/parsajiravand/the-background-task-that-waited-40-seconds-for-idle-5736</guid>
      <description>&lt;p&gt;You wire up &lt;code&gt;requestIdleCallback&lt;/code&gt; to autosave a draft to IndexedDB. Feels like magic on your machine — it fires within a few milliseconds, every time, because your test tab is doing nothing else.&lt;/p&gt;

&lt;p&gt;Then it ships. A user scrolls a long feed, glances at a video, types in a chat sidebar — normal, busy, human use of a web page. Somewhere in there, their draft doesn't save for 40 seconds. Sometimes it doesn't save at all before they close the tab.&lt;/p&gt;

&lt;p&gt;Nothing crashed. Nothing errored. The callback just never got a moment it was willing to call "idle."&lt;/p&gt;

&lt;h2&gt;
  
  
  What you probably think it does
&lt;/h2&gt;

&lt;p&gt;The name reads like a promise: schedule this, and the browser will run it soon, whenever it has a spare cycle. That's roughly true on a quiet page. It is not true in general, and the gap between those two is where bugs like the draft-save above come from.&lt;/p&gt;

&lt;p&gt;Here's the actual shape of the API:&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;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeRemaining&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// ms left in this idle period&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;didTimeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// true if forced by the timeout option&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;timeRemaining()&lt;/code&gt; tells you how much of the current idle slice is left — usually a number under 50, sometimes 0. &lt;code&gt;didTimeout&lt;/code&gt; tells you whether the browser actually found idle time or gave up and ran you anyway. That second field exists because the first promise — "I'll call you when I'm free" — has no deadline attached to it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idle time isn't a clock. It's a gap.
&lt;/h2&gt;

&lt;p&gt;The browser looks for idle periods between frames: after it's finished painting, handling input, and running its own housekeeping for that frame, whatever's left over becomes an idle window. If there's nothing left over — because something is animating, a timer fires every 16ms, or the user is mid-scroll — there's no window, and your callback simply doesn't run.&lt;/p&gt;

&lt;p&gt;Crucially, there's no ceiling on how long that can go on. A page that keeps finding &lt;em&gt;something&lt;/em&gt; to do every frame can starve every &lt;code&gt;requestIdleCallback&lt;/code&gt; on it indefinitely. Not "eventually, slowly" — indefinitely, until the page actually goes quiet.&lt;/p&gt;

&lt;p&gt;Watch it happen in your own browser: turn up the simulated load below and watch the scheduled callback stop firing.&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/requestidlecallback-can-wait-forever/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;That slider isn't decorative — it's genuinely keeping your browser tab busy the same way real work does (a rendering loop, a chat widget, a scroll handler). And you can watch the exact mechanism that made the draft-save bug real: the busier the page, the longer (and less predictably) the callback waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeout option doesn't fix this — it changes what breaks
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;requestIdleCallback&lt;/code&gt; takes a second argument for exactly this problem:&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;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saveTasks&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;2000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This says: &lt;em&gt;if you haven't found idle time within 2000ms, run it anyway.&lt;/em&gt; That sounds like the fix — a guaranteed upper bound. It is, but read what you actually got: the browser will now interrupt whatever it's doing to run your callback, &lt;code&gt;didTimeout&lt;/code&gt; will be &lt;code&gt;true&lt;/code&gt;, and &lt;code&gt;timeRemaining()&lt;/code&gt; inside it will report &lt;code&gt;0&lt;/code&gt;. You didn't get a fast idle slot. You got a forced, un-idle execution of code that was written on the assumption it would only ever run when the browser had room to spare.&lt;/p&gt;

&lt;p&gt;If that callback does anything nontrivial — serializing a large object, walking a DOM tree — a &lt;code&gt;timeout&lt;/code&gt;-forced run can itself become the janky frame you were trying to avoid. The option trades "might never run" for "might run at the worst possible moment." Pick your poison deliberately, per call site, instead of reaching for &lt;code&gt;timeout: 2000&lt;/code&gt; as a reflex.&lt;/p&gt;

&lt;h2&gt;
  
  
  It isn't Baseline — and the polyfill people write is a footgun
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;requestIdleCallback&lt;/code&gt; ships in Chromium and Firefox. &lt;strong&gt;Safari has never implemented it.&lt;/strong&gt; WebKit's objections to the original design are old and well documented, and there's no sign of that changing, so treat this the same way you'd treat any non-Baseline API: with a fallback, always.&lt;/p&gt;

&lt;p&gt;The fallback people reach for is &lt;code&gt;setTimeout&lt;/code&gt;, and it's worth writing carefully:&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;requestIdle&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;requestIdleCallback&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
  &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;didTimeout&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;timeRemaining&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cancelIdle&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;cancelIdleCallback&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note this is a &lt;em&gt;shim for the shape of the API&lt;/em&gt;, not a real implementation of idle detection — &lt;code&gt;setTimeout&lt;/code&gt; has no idea whether the main thread is actually free. In Safari, code gated on &lt;code&gt;requestIdleCallback&lt;/code&gt; runs on a timer instead, with no idle guarantee at all. That's usually fine for genuinely optional work, and it's exactly why the work you defer to this API should be optional in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not the same job as &lt;code&gt;scheduler.yield&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you've read about &lt;code&gt;scheduler.yield()&lt;/code&gt;, it's easy to lump these together as "ways to be nice to the main thread." They solve opposite problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt;&lt;/strong&gt; is for work you need to finish, soon, without blocking the browser while you do it. You yield, the browser catches up on input and paint, and your loop resumes almost immediately, at high priority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;requestIdleCallback&lt;/code&gt;&lt;/strong&gt; is for work you'd like to finish eventually, only if nothing more important is happening — and you're explicitly fine with "eventually" meaning "maybe not for a while."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;code&gt;scheduler.yield()&lt;/code&gt; (or its &lt;code&gt;setTimeout(0)&lt;/code&gt; fallback) for a big loop that has to complete. Use &lt;code&gt;requestIdleCallback&lt;/code&gt; for the things that are fine to skip entirely under load: precomputing a search index, pruning an in-memory cache, sending non-critical analytics, prefetching a route nobody's asked for yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix for the draft that didn't save
&lt;/h2&gt;

&lt;p&gt;Back to the autosave. The bug wasn't calling &lt;code&gt;requestIdleCallback&lt;/code&gt; — it was making it the &lt;em&gt;only&lt;/em&gt; path to something that had to happen. The fix keeps the idle callback for the common case (cheap, frequent, no rush) and adds a guaranteed path for the case that matters (the tab is closing, right now):&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;scheduleAutosave&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;requestIdle&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;saveDraft&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="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;1500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Belt and suspenders: this one is not allowed to wait for "idle."&lt;/span&gt;
&lt;span class="nb"&gt;window&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;pagehide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingDraft&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;requestIdleCallback&lt;/code&gt; handles the frequent, low-stakes saves. &lt;code&gt;pagehide&lt;/code&gt; and &lt;code&gt;sendBeacon&lt;/code&gt; handle the one save that's actually load-bearing, and they don't ask the browser's permission first.&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/requestidlecallback-can-wait-forever/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;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;requestIdleCallback&lt;/code&gt; isn't a scheduler that guarantees a turn — it's a favor you ask the browser, one it's allowed to keep declining as long as the page stays busy. Use it for work you're genuinely willing to skip, add a &lt;code&gt;timeout&lt;/code&gt; only when you've thought through what running late-and-forced actually costs, and never make it the only road to something that has to happen before the tab closes.&lt;/p&gt;

&lt;p&gt;Where have you used &lt;code&gt;requestIdleCallback&lt;/code&gt; — and did you give it a real fallback, or trust it to always eventually fire? I'm curious how many of us have a draft-save bug like this one sitting in production right now.&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>frontend</category>
    </item>
    <item>
      <title>Vue Composables: The Shared State Trap (+ Cheat Sheet)</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:37:11 +0000</pubDate>
      <link>https://dev.to/parsajiravand/vue-composables-the-shared-state-trap-cheat-sheet-37ia</link>
      <guid>https://dev.to/parsajiravand/vue-composables-the-shared-state-trap-cheat-sheet-37ia</guid>
      <description>&lt;p&gt;You build a &lt;code&gt;useCounter()&lt;/code&gt; composable, drop &lt;code&gt;&amp;lt;Counter /&amp;gt;&lt;/code&gt; on the page twice, and click the first button. Both counters go up.&lt;/p&gt;

&lt;p&gt;You didn't copy-paste a bug. You wrote twelve lines of completely ordinary-looking Composition API code, and Vue executed every one of them correctly. The surprise isn't a Vue bug — it's a gap in your mental model of what a composable actually &lt;em&gt;is&lt;/em&gt;, and that gap is exactly where a much more expensive version of this same mistake lives: on a server, where "the other counter" is a different user's browser tab.&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 two calls to the same composable sometimes share state and sometimes don't&lt;/li&gt;
&lt;li&gt;Predict it from reading the composable's source, without running the app&lt;/li&gt;
&lt;li&gt;Deliberately design shared (singleton) state when you actually want it&lt;/li&gt;
&lt;li&gt;Clean up a composable's side effects so they don't outlive the component that created them&lt;/li&gt;
&lt;li&gt;Recognize why this exact bug is more dangerous in server-rendered Vue than in the browser&lt;/li&gt;
&lt;li&gt;Keep the cheat sheet at the end open while you build your next composable&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You've written at least one composable — a &lt;code&gt;use*&lt;/code&gt; function that returns some &lt;code&gt;ref&lt;/code&gt;s and functions from &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;. You don't need to have hit this bug yet; you will recognize it the moment you see it. If &lt;code&gt;ref&lt;/code&gt; vs &lt;code&gt;reactive&lt;/code&gt; is still fuzzy, &lt;a href="https://dev.to/parsajiravand/vue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij"&gt;Vue Reactivity Explained: ref vs reactive&lt;/a&gt; is a good five minutes first — this article builds on it but doesn't require it.&lt;/p&gt;

&lt;p&gt;This article is written against &lt;strong&gt;Vue 3.5.42&lt;/strong&gt;, the current stable release (verified via the npm registry, September 2026). Vue 3.6 is still in release candidate; nothing here changes under it — composables are a code-organization pattern, not a runtime feature that Vapor Mode alters.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: two counters, one number&lt;/li&gt;
&lt;li&gt;The mental model: a composable is just a function&lt;/li&gt;
&lt;li&gt;Stage 1: state that lives inside the function&lt;/li&gt;
&lt;li&gt;Stage 2: composables with side effects need cleanup&lt;/li&gt;
&lt;li&gt;Stage 3: composables that call other composables&lt;/li&gt;
&lt;li&gt;Stage 4: when shared state is what you actually want&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;/ul&gt;

&lt;h2&gt;
  
  
  The problem: two counters, one number
&lt;/h2&gt;

&lt;p&gt;Here's the composable, written the way it looks in a hundred tutorials:&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;// useCounter.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// looks like "the counter's state" — but whose?&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useCounter&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;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&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;increment&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;And here's a component that uses it, dropped onto a page twice:&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;!-- Counter.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;useCounter&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="s1"&gt;./useCounter&lt;/span&gt;&lt;span class="dl"&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;increment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCounter&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;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"increment"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Count: &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="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&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;!-- Page.vue --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Counter&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Counter&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&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;Click the first button five times. Both buttons read &lt;code&gt;Count: 5&lt;/code&gt;. Nothing crashed, nothing warned you — &lt;code&gt;useCounter()&lt;/code&gt; did precisely what its source says: return the one &lt;code&gt;count&lt;/code&gt; that exists.&lt;/p&gt;

&lt;p&gt;The bug is not in &lt;code&gt;&amp;lt;Counter /&amp;gt;&lt;/code&gt;. It's a single misplaced line in &lt;code&gt;useCounter.js&lt;/code&gt; — and to see which line, you need the rule that explains both the broken version and the correct one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: a composable is just a function
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A composable has no special relationship to "a component's state." It's a plain JavaScript function, and JavaScript's own scoping rules decide everything.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;State declared &lt;strong&gt;inside&lt;/strong&gt; the function body is created fresh every time the function &lt;em&gt;runs&lt;/em&gt;. Since &lt;code&gt;&amp;lt;Counter /&amp;gt;&lt;/code&gt; calls &lt;code&gt;useCounter()&lt;/code&gt; once per component instance, each instance gets its own &lt;code&gt;ref&lt;/code&gt; — private state.&lt;/li&gt;
&lt;li&gt;State declared &lt;strong&gt;outside&lt;/strong&gt; the function — at module scope, in the file that defines it — is created exactly &lt;strong&gt;once&lt;/strong&gt;, the first time anything imports the module. Every subsequent call to &lt;code&gt;useCounter()&lt;/code&gt; returns a reference to that same &lt;code&gt;ref&lt;/code&gt;. That's not a bug in the composable pattern; it's how ES modules always work, for the same reason a module-level &lt;code&gt;let cache = new Map()&lt;/code&gt; is shared by every caller too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vue's Composition API rules (&lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;reactive&lt;/code&gt;, &lt;code&gt;onMounted&lt;/code&gt;, &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt;, …) never asked &lt;em&gt;where&lt;/em&gt; you put your &lt;code&gt;const&lt;/code&gt;. You decided that, with ordinary variable scope, before Vue ever entered the picture. The composable naming convention (&lt;code&gt;useXxx&lt;/code&gt;) signals &lt;em&gt;what it does&lt;/em&gt;, not &lt;em&gt;how many copies of it exist&lt;/em&gt; — that second question is answered by reading exactly one thing: is this &lt;code&gt;ref()&lt;/code&gt; call inside the exported function, or outside it?&lt;/p&gt;

&lt;p&gt;That's the whole model. Everything below is a consequence of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: state that lives inside the function
&lt;/h2&gt;

&lt;p&gt;Move &lt;code&gt;count&lt;/code&gt; inside &lt;code&gt;useCounter&lt;/code&gt;, and each call becomes its own closure over its own &lt;code&gt;ref&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// useCounter.js — fixed&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useCounter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// created fresh, once per CALL, not once per FILE&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&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;increment&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; nothing about the export changed — same function name, same return shape. Only the &lt;em&gt;scope&lt;/em&gt; of &lt;code&gt;count&lt;/code&gt; moved from module-level to function-level. Now two &lt;code&gt;&amp;lt;Counter /&amp;gt;&lt;/code&gt; instances each run &lt;code&gt;useCounter()&lt;/code&gt; independently, each closing over its own &lt;code&gt;count&lt;/code&gt;, and clicking one leaves the other alone.&lt;/p&gt;

&lt;p&gt;This is the default you want almost every time. If you're writing a composable and you haven't deliberately decided otherwise, put the state inside the function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: composables with side effects need cleanup
&lt;/h2&gt;

&lt;p&gt;Composables aren't only about state — they often start something that needs to be stopped. A classic:&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;// useMousePosition.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onUnmounted&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="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMousePosition&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;
    &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;onMounted&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;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mousemove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nf"&gt;onUnmounted&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;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mousemove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- easy to forget&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the &lt;code&gt;onUnmounted&lt;/code&gt; line, and every component that ever used this composable leaves a &lt;code&gt;mousemove&lt;/code&gt; listener attached to &lt;code&gt;window&lt;/code&gt; forever, each one still running its &lt;code&gt;update&lt;/code&gt; closure against a &lt;code&gt;ref&lt;/code&gt; nobody reads anymore. It's not a crash — it's a slow, silent leak that only shows up as "the page gets janky after navigating around for a while."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; a composable's lifecycle hooks (&lt;code&gt;onMounted&lt;/code&gt;, &lt;code&gt;onUnmounted&lt;/code&gt;, &lt;code&gt;onScopeDispose&lt;/code&gt;) tie the side effect to whichever component (or, as Stage 3 shows, effect scope) called the composable. If you start something, you are responsible for stopping it in the matching hook — Vue does not infer the teardown from the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: composables that call other composables
&lt;/h2&gt;

&lt;p&gt;Composables compose — &lt;code&gt;useMousePosition&lt;/code&gt; might be built from a lower-level &lt;code&gt;useEventListener&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// useEventListener.js&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;onMounted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onUnmounted&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="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useEventListener&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;event&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="nf"&gt;onMounted&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;target&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="nx"&gt;event&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="nf"&gt;onUnmounted&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&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 fine called from &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;, because &lt;code&gt;onMounted&lt;/code&gt;/&lt;code&gt;onUnmounted&lt;/code&gt; register against the &lt;em&gt;currently running&lt;/em&gt; component instance — and during &lt;code&gt;setup()&lt;/code&gt;, there is one. It breaks the moment you call it from somewhere that isn't a component, like a Pinia store's &lt;code&gt;setup&lt;/code&gt; function running at app-init time, or a plain test file: there's no "current instance" for the hook to attach to, Vue logs a dev warning, and the callback simply never fires.&lt;/p&gt;

&lt;p&gt;The general-purpose fix is &lt;code&gt;onScopeDispose&lt;/code&gt;, which ties into Vue's &lt;a href="https://vuejs.org/api/reactivity-advanced.html#effectscope" rel="noopener noreferrer"&gt;effect scope&lt;/a&gt; instead of specifically a component instance — and a component's own setup function is itself one:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onScopeDispose&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="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useEventListener&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;event&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="nx"&gt;target&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="nx"&gt;event&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="nf"&gt;onScopeDispose&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&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;onScopeDispose&lt;/code&gt; fires whenever the enclosing effect scope tears down — a component unmounting, or an &lt;code&gt;effectScope().stop()&lt;/code&gt; call you make yourself outside any component. It's the one cleanup hook that works the same whether the composable is called from a component or from plain code, which is exactly what a composable meant to be reused outside components needs.&lt;/p&gt;

&lt;p&gt;If you skipped &lt;a href="https://dev.to/parsajiravand/vue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij"&gt;Vue Reactivity Explained&lt;/a&gt;, the same destructuring rule from that article applies here too: if &lt;code&gt;useEventListener&lt;/code&gt; returned a &lt;code&gt;reactive()&lt;/code&gt; object instead of individual &lt;code&gt;ref&lt;/code&gt;s, destructuring it in the caller would silently drop reactivity. Composables should almost always return an object of &lt;code&gt;ref&lt;/code&gt;s (or use &lt;code&gt;toRefs&lt;/code&gt; on a &lt;code&gt;reactive&lt;/code&gt; one) so a caller can safely write &lt;code&gt;const { x, y } = useMousePosition()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: when shared state is what you actually want
&lt;/h2&gt;

&lt;p&gt;Sometimes module-scoped state is the correct design, not a bug — a logged-in user, a theme preference, a WebSocket connection every component should see the same instance of. The fix isn't "always put state inside the function"; it's "put it outside the function on purpose, and say so":&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;// useAuthUser.js — a deliberate singleton, not an accident&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readonly&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="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Module scope, intentionally: every caller across the whole app&lt;/span&gt;
&lt;span class="c1"&gt;// shares this exact user. This is a global store, not per-call state.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useAuthUser&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;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&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;logout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// readonly() stops callers from doing `user.value = ...` directly —&lt;/span&gt;
  &lt;span class="c1"&gt;// every write goes through login()/logout(), which is the whole point&lt;/span&gt;
  &lt;span class="c1"&gt;// of centralizing this state in the first place.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&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;_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;logout&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; the code that makes this a singleton is identical in shape to the bug from the opening example — a &lt;code&gt;ref&lt;/code&gt; declared outside the function. The difference is entirely in intent and in guarding the write path. A comment explaining &lt;em&gt;why&lt;/em&gt; this one is module-scoped is not decoration; it's the one piece of information a reader can't get from the code's shape, because the shape is the same as the accidental version.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-side rendering makes this bug worse, not just different.&lt;/strong&gt; In the browser, a module-scoped &lt;code&gt;ref&lt;/code&gt; is shared across components in &lt;em&gt;one user's&lt;/em&gt; page — annoying, but contained. On a Vue SSR server (Nuxt or a custom setup), a Node process typically renders many different users' requests using the &lt;em&gt;same&lt;/em&gt; loaded module. A &lt;code&gt;ref&lt;/code&gt; created at module scope is created once when the server starts, not once per request — so &lt;code&gt;useAuthUser&lt;/code&gt;'s state can leak from the user who requested it first into the response sent to the next user. This is the single most important reason "does this composable's state live inside or outside the function" is worth getting right on purpose, not by habit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing a composable outside a component throws or warns.&lt;/strong&gt; Calling &lt;code&gt;onMounted&lt;/code&gt;/&lt;code&gt;onUnmounted&lt;/code&gt; with no active component instance triggers a dev-mode warning and does nothing. Wrap the call in &lt;code&gt;effectScope()&lt;/code&gt; when unit-testing a composable directly, so lifecycle-style hooks (&lt;code&gt;onScopeDispose&lt;/code&gt;) have somewhere to attach:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;effectScope&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="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;effectScope&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&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="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;increment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCounter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// triggers any onScopeDispose cleanup registered inside&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot Module Replacement can hide the bug in dev.&lt;/strong&gt; Vite's HMR sometimes re-executes a module and resets its module-scope state, which can make a shared-state leak look fixed after a save — then reappear on a full reload. Don't trust "it works now" from an HMR session; reload the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getCurrentInstance()&lt;/code&gt; is not the fix.&lt;/strong&gt; It's tempting to reach for it to detect "am I inside a component," but it's explicitly documented as an internal API for advanced/library use, not a general escape hatch — prefer designing the composable so it doesn't need to know.&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 state inside the function.&lt;/strong&gt; Only hoist state to module scope when you specifically want one shared instance across the whole app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say so when you do.&lt;/strong&gt; A one-line comment ("module-scope singleton, shared across the app") costs nothing and saves the next reader from re-deriving your intent from scope rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return &lt;code&gt;ref&lt;/code&gt;s, not a raw &lt;code&gt;reactive()&lt;/code&gt; object&lt;/strong&gt;, so callers can destructure without losing reactivity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean up every side effect&lt;/strong&gt; a composable starts — a listener, a timer, a subscription — with &lt;code&gt;onUnmounted&lt;/code&gt; for component-only composables, &lt;code&gt;onScopeDispose&lt;/code&gt; for anything meant to be reusable outside a component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For real app-wide state, reach for Pinia&lt;/strong&gt; once it grows past one or two values. It gives you the same "one shared store" idea, but with devtools time-travel, SSR-safe store instantiation per request, and a name (&lt;code&gt;defineStore&lt;/code&gt;) that makes "this is intentionally shared" obvious from the import alone.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Why didn't Vue warn me that my composable's state was shared?
&lt;/h3&gt;

&lt;p&gt;Because there's nothing to warn about — &lt;code&gt;const count = ref(0)&lt;/code&gt; at module scope is completely valid JavaScript and Vue reactivity, doing exactly what it's written to do. The "bug" is a mismatch between your intent and the code's actual scope, which no linter can read your mind about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this affect &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; the same way?
&lt;/h3&gt;

&lt;p&gt;No — &lt;code&gt;provide&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt; is scoped to the component tree by design; each provider creates its own value for its own descendants, so two unrelated component trees don't share it. The module-scope trap is specific to state declared directly in a &lt;code&gt;.js&lt;/code&gt; file outside any function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a composable just a React-style custom hook?
&lt;/h3&gt;

&lt;p&gt;They solve the same problem — extracting stateful logic into a reusable function — but the mechanics differ. A composable's &lt;code&gt;ref&lt;/code&gt;s are proxies you can read anywhere without a special calling rule, and there is no "rules of hooks" about call order; a custom hook's state is closed over by React's fiber and re-runs the whole function on every render. The shared-state trap in this article has no React equivalent, because React hooks don't have a module-scope option — &lt;code&gt;useState&lt;/code&gt; is always per-component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should every composable clean up after itself, even ones that don't touch the DOM?
&lt;/h3&gt;

&lt;p&gt;If it started anything — a listener, an interval, a subscription to something outside Vue's reactivity — yes. If it only computed derived values from its inputs (&lt;code&gt;computed&lt;/code&gt;, plain functions), there's nothing to clean up; &lt;code&gt;computed&lt;/code&gt; refs are garbage-collected with everything else that stops referencing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I tell from the outside whether a composable shares state?
&lt;/h3&gt;

&lt;p&gt;Only by reading its source (or its docs). The call site — &lt;code&gt;const { count, increment } = useCounter()&lt;/code&gt; — looks identical either way. That asymmetry is exactly why Stage 4's naming/comment convention matters: the information isn't visible where you'd need it most.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/vue-weekly-composables-shared-state-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;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-caller state (default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ref()&lt;/code&gt; declared &lt;strong&gt;inside&lt;/strong&gt; the exported function&lt;/td&gt;
&lt;td&gt;Each call to &lt;code&gt;useX()&lt;/code&gt; gets its own private state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared/singleton state&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ref()&lt;/code&gt; declared &lt;strong&gt;outside&lt;/strong&gt; the function, at module scope&lt;/td&gt;
&lt;td&gt;Every call to &lt;code&gt;useX()&lt;/code&gt; returns the same state — intentional or not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe destructuring&lt;/td&gt;
&lt;td&gt;Return individual &lt;code&gt;ref&lt;/code&gt;s: &lt;code&gt;return { count, increment }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Caller can destructure without losing reactivity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component-only cleanup&lt;/td&gt;
&lt;td&gt;&lt;code&gt;onUnmounted(() =&amp;gt; …)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs when the owning component unmounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reusable-anywhere cleanup&lt;/td&gt;
&lt;td&gt;&lt;code&gt;onScopeDispose(() =&amp;gt; …)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs when the enclosing effect scope stops — component or manual &lt;code&gt;effectScope()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protect a singleton's writes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;return { user: readonly(_user), login, logout }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Callers can read but must go through your functions to write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test a composable in isolation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;effectScope().run(() =&amp;gt; { … })&lt;/code&gt;, then &lt;code&gt;.stop()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Gives lifecycle-style hooks somewhere to attach outside a component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSR danger sign&lt;/td&gt;
&lt;td&gt;A module-scope &lt;code&gt;ref&lt;/code&gt; holding per-user data (auth, cart, …)&lt;/td&gt;
&lt;td&gt;Can leak between requests on a server — make it request-scoped or use Pinia&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;A composable is a function first; JavaScript's own variable scope — not anything Vue-specific — decides whether its state is private per call or one shared singleton.&lt;/li&gt;
&lt;li&gt;State inside the function body is the safe default; state outside it is a deliberate design choice that deserves a comment.&lt;/li&gt;
&lt;li&gt;Every side effect a composable starts needs a matching cleanup, via &lt;code&gt;onUnmounted&lt;/code&gt; or the more general &lt;code&gt;onScopeDispose&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The same "module scope = shared" rule that causes two counters to double-count in the browser can leak one user's data into another user's response on an SSR server — treat it as a correctness issue, not just a UI quirk.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Think it clicked? &lt;strong&gt;&lt;a href="https://bestpractic.org/blog/vue-weekly-composables-shared-state-trap/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;
  
  
  Ending
&lt;/h2&gt;

&lt;p&gt;Those two counters that moved together weren't broken — they were exactly one &lt;code&gt;ref&lt;/code&gt; doing exactly what a &lt;code&gt;ref&lt;/code&gt; declared outside a function always does. Once you can see that scope decision sitting in the source, every "why are these two components sharing state" question stops being a mystery and starts being a one-line answer you can find by reading, not debugging.&lt;/p&gt;

&lt;p&gt;Where has this bitten you — a shared composable, or the SSR version of it? Tell me in the comments.&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>vue</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Dropdown You've Been Faking for a Decade</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:36:39 +0000</pubDate>
      <link>https://dev.to/parsajiravand/the-dropdown-youve-been-faking-for-a-decade-5b10</link>
      <guid>https://dev.to/parsajiravand/the-dropdown-youve-been-faking-for-a-decade-5b10</guid>
      <description>&lt;p&gt;Design wants a country picker. Nothing fancy — a dropdown, a small flag icon next to each name. You reach for &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, type the first &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt;, and hit the wall every frontend dev hits eventually: an &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; can only ever show text. No &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, no icon span, nothing. The browser renders whatever markup you put inside it as a plain string.&lt;/p&gt;

&lt;p&gt;So you do what everyone does. You build a fake one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dropdown you build instead
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;&amp;lt;div role="listbox"&amp;gt;&lt;/code&gt;, a button that toggles it, &lt;code&gt;&amp;lt;div role="option"&amp;gt;&lt;/code&gt; children with real icons inside, arrow-key handlers, a click-outside listener to close it. It's maybe 150 lines. It works in the demo. Ship it.&lt;br&gt;
&lt;/p&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;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"select"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"country-select"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;aria-haspopup=&lt;/span&gt;&lt;span class="s"&gt;"listbox"&lt;/span&gt; &lt;span class="na"&gt;aria-expanded=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🇨🇦 Canada&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"listbox"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"option"&lt;/span&gt; &lt;span class="na"&gt;data-value=&lt;/span&gt;&lt;span class="s"&gt;"ca"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🇨🇦 Canada&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"option"&lt;/span&gt; &lt;span class="na"&gt;data-value=&lt;/span&gt;&lt;span class="s"&gt;"jp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🇯🇵 Japan&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"option"&lt;/span&gt; &lt;span class="na"&gt;data-value=&lt;/span&gt;&lt;span class="s"&gt;"br"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🇧🇷 Brazil&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then someone drops this select inside a card component — rounded corners, &lt;code&gt;overflow: hidden&lt;/code&gt; so a stray image never bleeds past the border radius. Totally normal CSS. And now the popup, which is just a child &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; positioned &lt;code&gt;absolute&lt;/code&gt;, gets sliced off at the card's edge the moment it's taller than the space below the trigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it — watch the fix disappear back into the bug
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/customizable-select-appearance-base-select/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;You didn't do anything wrong. &lt;code&gt;overflow: hidden&lt;/code&gt; clips &lt;em&gt;any&lt;/em&gt; descendant, and a hand-built popup is, structurally, just a descendant. The standard escape hatch is &lt;code&gt;position: fixed&lt;/code&gt; plus &lt;code&gt;getBoundingClientRect()&lt;/code&gt; to glue the popup under the trigger by hand — and now you're also re-running that math on every &lt;code&gt;scroll&lt;/code&gt; and &lt;code&gt;resize&lt;/code&gt; event, and remembering to tear the listeners down when the select unmounts. None of this is exotic. It's just work the native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; used to do for you, that you signed back up for the moment you needed one icon.&lt;/p&gt;

&lt;p&gt;And the part that doesn't show up in a demo video: keyboard behavior. Type-ahead (press "j" and jump to "Japan"), Home/End, wraparound at the list's edges, closing on Escape and returning focus to the trigger, announcing the right state to a screen reader as options are highlighted — a real &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; gets every one of those for free, in every browser, on every OS. The &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/listbox/" rel="noopener noreferrer"&gt;WAI-ARIA Authoring Practices listbox pattern&lt;/a&gt; exists as a reference implementation precisely because getting all of that right by hand, consistently, is genuinely hard. Most homegrown dropdowns implement a fraction of it and call it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; now has an opt-in that unlocks styling without giving up any of that native behavior:&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;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base-select&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* the popup is now a real, styleable target */&lt;/span&gt;
&lt;span class="nt"&gt;select&lt;/span&gt;&lt;span class="nd"&gt;::picker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;select&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#2a2f3a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things happen the moment you set &lt;code&gt;appearance: base-select&lt;/code&gt;. First, &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; stops being text-only — it can hold real markup, so the flag icon goes right back where you originally wanted it:&lt;br&gt;
&lt;/p&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;select&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;selectedcontent&amp;gt;&amp;lt;/selectedcontent&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"ca"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/flags/ca.svg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt; Canada&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"jp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/flags/jp.svg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt; Japan&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;option&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"br"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/flags/br.svg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt; Brazil&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/select&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;&amp;lt;selectedcontent&amp;gt;&lt;/code&gt; element is new too — it mirrors whatever content the chosen &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; holds into the closed button, so the flag shows up on the trigger, not just inside the open popup.&lt;/p&gt;

&lt;p&gt;Second, and this is the part that actually kills the clipping bug from the wrong-way section: &lt;code&gt;::picker(select)&lt;/code&gt; doesn't render as a descendant &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; at all. It's promoted to the &lt;strong&gt;top layer&lt;/strong&gt; — the same rendering layer &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; and the Popover API use — so it paints above the entire page, unclipped by any ancestor's &lt;code&gt;overflow: hidden&lt;/code&gt;, with no &lt;code&gt;z-index&lt;/code&gt; or &lt;code&gt;position: fixed&lt;/code&gt; math required. The exact bug in the playground above simply can't happen to it, because it was never inside the clipping container to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat
&lt;/h2&gt;

&lt;p&gt;This is a real, working CSS feature — not a proposal — but support isn't universal yet. It shipped first in Chromium browsers; Firefox and Safari have it in progress at various stages. Check &lt;a href="https://caniuse.com/mdn-css_properties_appearance_base-select" rel="noopener noreferrer"&gt;caniuse&lt;/a&gt; before you rely on it, and reach for it as &lt;strong&gt;progressive enhancement&lt;/strong&gt;: wrap the opt-in behind &lt;code&gt;@supports (appearance: base-select)&lt;/code&gt; and let browsers without support fall back to a plain &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, which still works, still submits, still has icon-free but fully accessible options. You lose the flags, not the form.&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="k"&gt;@supports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base-select&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;appearance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base-select&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole migration. No polyfill, no JS fallback path to maintain — the feature detection &lt;em&gt;is&lt;/em&gt; the fallback.&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/customizable-select-appearance-base-select/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;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;The custom &lt;code&gt;&amp;lt;div role="listbox"&amp;gt;&lt;/code&gt; component your team maintains — the one with the scroll listener and the &lt;code&gt;getBoundingClientRect()&lt;/code&gt; call and the open GitHub issue about it not closing right on iOS — was never really a design requirement. It was a workaround for &lt;code&gt;&amp;lt;option&amp;gt;&lt;/code&gt; being text-only and the popup being an ordinary, clippable &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. Both of those are gone now, behind one CSS property and one new element.&lt;/p&gt;

&lt;p&gt;Are you still shipping a hand-rolled dropdown for something this small, or are you already behind &lt;code&gt;@supports&lt;/code&gt; on the real thing? I'd genuinely like to know which.&lt;/p&gt;




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

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

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

</description>
      <category>css</category>
      <category>html</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Next.js proxy.ts Explained (with Cheat Sheet)</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:36:08 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nextjs-proxyts-explained-with-cheat-sheet-j4i</link>
      <guid>https://dev.to/parsajiravand/nextjs-proxyts-explained-with-cheat-sheet-j4i</guid>
      <description>&lt;p&gt;Your team wrote &lt;code&gt;middleware.ts&lt;/code&gt; carefully — Edge-safe imports only, &lt;code&gt;jose&lt;/code&gt; instead of &lt;code&gt;jsonwebtoken&lt;/code&gt;, no direct database calls — because that's what Edge middleware demanded. Then you upgraded to Next.js 16, skimmed the release notes, and moved on. Nothing broke. Which is exactly the problem: &lt;code&gt;middleware.ts&lt;/code&gt; still runs, but it's now the &lt;em&gt;deprecated&lt;/em&gt; way to do the one job every non-trivial app needs — checking a request before a single line of your app runs. The framework renamed the file, moved the runtime under it, and left the old name working just long enough for teams to miss the change entirely.&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;strong&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/strong&gt; is, why Next.js 16 renamed &lt;code&gt;middleware.ts&lt;/code&gt; to it, and what actually changed under the hood.&lt;/li&gt;
&lt;li&gt;State exactly which runtime &lt;code&gt;proxy.ts&lt;/code&gt; runs on — and why you can no longer choose.&lt;/li&gt;
&lt;li&gt;Migrate an existing &lt;code&gt;middleware.ts&lt;/code&gt; file with the official codemod, including the config options that renamed alongside it.&lt;/li&gt;
&lt;li&gt;Recognize the one capability trade Next.js made, and decide whether it affects your app.&lt;/li&gt;
&lt;li&gt;Write a &lt;code&gt;proxy.ts&lt;/code&gt; that checks auth, sets a header, and rewrites a request — the shape that covers most real uses.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You've shipped a Next.js App Router app and have (or have used) a &lt;code&gt;middleware.ts&lt;/code&gt; file for things like auth checks or redirects. You don't need prior Edge-runtime experience — this article explains what that runtime was and why it mattered.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: a boundary with two names and a hidden runtime&lt;/li&gt;
&lt;li&gt;The mental model: proxy.ts is the network boundary, not a request handler&lt;/li&gt;
&lt;li&gt;Migrating middleware.ts to proxy.ts, step by step&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a boundary with two names and a hidden runtime
&lt;/h2&gt;

&lt;p&gt;This article is written against &lt;strong&gt;Next.js 16.3&lt;/strong&gt; (the current Active LTS release, verified against the framework's own file-convention and upgrade docs, and its GitHub releases, in September 2026). If you're reading this from a much later version, re-check the docs linked below before trusting a specific detail — that's the honest habit this series keeps asking of you, and this topic is exactly why.&lt;/p&gt;

&lt;p&gt;Here's the wrong-way-first version, because it's what most teams actually did. A Next.js 15 app has this &lt;code&gt;middleware.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts — Next.js 15, Edge runtime (the only option)&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;NextResponse&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;next/server&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;jwtVerify&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;jose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Edge-safe; jsonwebtoken would not run here&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cookie&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/session=&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// must be Edge-runtime-compatible&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matcher&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;/dashboard/:path*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every choice in that file — &lt;code&gt;jose&lt;/code&gt; over &lt;code&gt;jsonwebtoken&lt;/code&gt;, no direct Postgres client, no &lt;code&gt;fs&lt;/code&gt; — exists because Edge middleware ran on a restricted, non-Node runtime. That constraint was real and it shaped how an entire generation of Next.js auth code got written.&lt;/p&gt;

&lt;p&gt;Then Next.js 16 ships, and the docs start talking about &lt;code&gt;proxy.ts&lt;/code&gt; instead. The team upgrades. &lt;code&gt;middleware.ts&lt;/code&gt; still runs — Next.js kept it working on purpose — so nothing visibly breaks, and the rename gets filed under "not our problem yet." Two things go quietly wrong from there:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;New code in the same repo starts appearing as &lt;code&gt;proxy.ts&lt;/code&gt; (copied from a blog post, a teammate's other project, or the docs), and now the app has both a &lt;code&gt;middleware.ts&lt;/code&gt; and a mental model split between two names for the same job.&lt;/li&gt;
&lt;li&gt;Someone "helpfully" migrates the file and copies the runtime opt-in along with it:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// proxy.ts — this line is now meaningless&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;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ❌ ignored — proxy always runs on Node.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; doesn't fail loudly here — it just runs on the Node.js runtime regardless, because that runtime &lt;strong&gt;cannot be configured&lt;/strong&gt;. The Edge runtime isn't an option for &lt;code&gt;proxy.ts&lt;/code&gt; at all. If your mental model is still "Edge middleware, just renamed," you'll misjudge what you can and can't do inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: proxy.ts is the network boundary, not a request handler
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; &lt;code&gt;proxy.ts&lt;/code&gt; is the one file that sits in front of your entire app, on every request that matches its &lt;code&gt;matcher&lt;/code&gt;, and runs before the App Router resolves a route — before any layout, page, Server Component, or Server Action executes. Next.js 16 renamed it from &lt;code&gt;middleware.ts&lt;/code&gt; specifically to stop you from thinking of it as a request handler in the Express sense (a function in a chain, alongside your route logic). It's a &lt;strong&gt;network boundary&lt;/strong&gt;: the place where you decide whether a request is even allowed to reach the app, and what it's allowed to carry in with it (a header, a rewritten path, a redirect).&lt;/p&gt;

&lt;p&gt;The rename came with a runtime decision, not just new vocabulary: &lt;code&gt;proxy.ts&lt;/code&gt; runs exclusively on the &lt;strong&gt;Node.js runtime&lt;/strong&gt;. There is no &lt;code&gt;export const runtime = "edge"&lt;/code&gt; for it — the option doesn't exist, because a proxy that always runs the same way, in the same environment, is the entire point. &lt;code&gt;middleware.ts&lt;/code&gt; is still there for teams that specifically need Edge behavior, but it's documented as deprecated, scheduled for removal in a future major version. You're not choosing between two files going forward; you're on a deprecation clock.&lt;/p&gt;

&lt;p&gt;What that buys you: &lt;code&gt;proxy.ts&lt;/code&gt; can use anything the Node.js runtime supports — Node's built-in &lt;code&gt;crypto&lt;/code&gt;, a real database driver for a session lookup, any npm package that assumes Node — without auditing it for Edge compatibility first. What it costs you: if your app specifically wanted Edge's global, low-latency execution for this boundary, that option is gone for new code. For the overwhelming majority of auth/redirect/rewrite logic, that trade is invisible; for a handful of latency-critical, globally-distributed checks, it's worth knowing about before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating middleware.ts to proxy.ts, step by step
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — run the codemod, don't hand-edit.&lt;/strong&gt; Next.js ships an automated migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @next/codemod@canary middleware-to-proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This renames &lt;code&gt;middleware.ts&lt;/code&gt; → &lt;code&gt;proxy.ts&lt;/code&gt;, renames the exported &lt;code&gt;middleware&lt;/code&gt; function to &lt;code&gt;proxy&lt;/code&gt;, and updates the config keys that renamed alongside it (for example &lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt; → &lt;code&gt;skipProxyUrlNormalize&lt;/code&gt;, and &lt;code&gt;experimental.middlewareClientMaxBodySize&lt;/code&gt; → &lt;code&gt;experimental.proxyClientMaxBodySize&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt;). Run it, then read the diff — a codemod is a strong first draft, not a substitute for review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; the codemod changes names, not behavior. Whatever your middleware did, your proxy does identically — the boundary's job hasn't moved, only its label and its guaranteed runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — delete any runtime opt-in.&lt;/strong&gt; If your old file had &lt;code&gt;export const config = { runtime: "edge" }&lt;/code&gt; or similar, remove it. It has no effect on &lt;code&gt;proxy.ts&lt;/code&gt;, and leaving it in is the kind of thing that confuses the next engineer more than it confuses the framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — keep the matcher, unmodified.&lt;/strong&gt; The &lt;code&gt;matcher&lt;/code&gt; config that scopes which paths trigger the boundary is unchanged:&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;// proxy.ts&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&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;/dashboard/:path*&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;/api/protected/:path*&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;&lt;strong&gt;Step 4 — now you can simplify, if it helps.&lt;/strong&gt; Because you're guaranteed Node.js, you can replace an Edge-safe workaround with the straightforward version, if one exists:&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;// proxy.ts — Next.js 16, Node.js runtime (the only option, and now a guarantee)&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;NextResponse&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;next/server&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;jwtVerify&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;jose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// still works fine — no need to rip it out&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;NextRequest&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;next/server&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;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&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;loginUrl&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;loginUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loginUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matcher&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;/dashboard/:path*&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;&lt;strong&gt;Key concept:&lt;/strong&gt; nothing here &lt;em&gt;had&lt;/em&gt; to change — &lt;code&gt;jose&lt;/code&gt; runs fine on Node.js too. The point isn't "rewrite everything," it's that you're no longer required to reach for an Edge-safe library when a plain Node one would do, and you won't hit a surprise if a dependency assumes &lt;code&gt;Buffer&lt;/code&gt; or &lt;code&gt;crypto.createHmac&lt;/code&gt; exists.&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;middleware.ts&lt;/code&gt; still works — for now.&lt;/strong&gt; Next.js 16 didn't remove it; it's deprecated and slated for removal in a future major version. If you need the Edge runtime specifically (for its global execution model), keep using &lt;code&gt;middleware.ts&lt;/code&gt; with its &lt;code&gt;runtime&lt;/code&gt; opt-in and track the deprecation notice for when that stops being an option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runtime is not configurable, in either direction.&lt;/strong&gt; You can't opt &lt;code&gt;proxy.ts&lt;/code&gt; into Edge, and there's no flag to force &lt;code&gt;middleware.ts&lt;/code&gt; onto Node.js. The two files map to two fixed runtimes; migrating means accepting the new one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config keys renamed, not just the file.&lt;/strong&gt; If your &lt;code&gt;next.config.js&lt;/code&gt; sets &lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt; or &lt;code&gt;experimental.middlewareClientMaxBodySize&lt;/code&gt;, those need the &lt;code&gt;proxy&lt;/code&gt;-prefixed equivalents after migration — the codemod handles this, a manual rename easily misses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This doesn't remove the Edge runtime from Next.js.&lt;/strong&gt; Route Handlers and pages can still opt into the Edge runtime where it's supported. The one place Edge specifically disappeared is the network-boundary file — don't over-generalize the change to the rest of the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rewrite/redirect logic itself hasn't changed.&lt;/strong&gt; &lt;code&gt;NextResponse.next()&lt;/code&gt;, &lt;code&gt;.redirect()&lt;/code&gt;, &lt;code&gt;.rewrite()&lt;/code&gt;, and reading/writing cookies and headers all work the same way in &lt;code&gt;proxy.ts&lt;/code&gt; as they did in &lt;code&gt;middleware.ts&lt;/code&gt;. The migration is about the file's name, its exported function's name, and its runtime — not its API.&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;proxy.ts&lt;/code&gt; for boundary decisions&lt;/strong&gt;, not business logic: auth gating, locale/region redirects, A/B routing, header injection, and blocking bad requests before they cost you a route render. If a check needs your app's Server Components or database models to decide, it usually belongs past the boundary, not inside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the codemod on every &lt;code&gt;middleware.ts&lt;/code&gt; you own&lt;/strong&gt;, even ones that "still work fine." The deprecation clock is real, and doing it now — while you can compare the diff against a file you understand — is cheaper than doing it later under a removal deadline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the &lt;code&gt;matcher&lt;/code&gt; as narrow as the job needs.&lt;/strong&gt; A boundary that runs on every request, including static assets it doesn't care about, is pure overhead; scope it to the paths that actually need the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't move Edge-specific code into &lt;code&gt;proxy.ts&lt;/code&gt; unexamined.&lt;/strong&gt; If a library was chosen specifically for Edge compatibility, it's fine to leave it — but don't assume you now need a &lt;em&gt;different&lt;/em&gt; library, either. Change what the runtime actually requires you to change, nothing more.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is &lt;code&gt;proxy.ts&lt;/code&gt; a completely new file, or a rename?
&lt;/h3&gt;

&lt;p&gt;It's a rename with a runtime attached. Same conceptual job as &lt;code&gt;middleware.ts&lt;/code&gt; — code that runs before the App Router resolves a route — but the exported function is now called &lt;code&gt;proxy&lt;/code&gt;, the file is &lt;code&gt;proxy.ts&lt;/code&gt;, and it always runs on the Node.js runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to migrate right now?
&lt;/h3&gt;

&lt;p&gt;No — &lt;code&gt;middleware.ts&lt;/code&gt; still works in Next.js 16. But it's documented as deprecated and due for removal in a future major version, so treat this as scheduled work, not optional cleanup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run &lt;code&gt;proxy.ts&lt;/code&gt; on the Edge runtime if I really want to?
&lt;/h3&gt;

&lt;p&gt;No. The runtime for &lt;code&gt;proxy.ts&lt;/code&gt; is fixed to Node.js and isn't configurable. If your use case specifically needs Edge, that's what &lt;code&gt;middleware.ts&lt;/code&gt; remains for, while it's still available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will the migration change what my auth/redirect logic does?
&lt;/h3&gt;

&lt;p&gt;It shouldn't. The codemod renames the file, the function, and the handful of config keys that renamed with it. The request/response API — &lt;code&gt;NextResponse.next()&lt;/code&gt;, &lt;code&gt;.redirect()&lt;/code&gt;, &lt;code&gt;.rewrite()&lt;/code&gt;, cookies, headers — is unchanged.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this affect Route Handlers or pages that use the Edge runtime?
&lt;/h3&gt;

&lt;p&gt;No. The Edge-runtime removal is specific to the network-boundary file. Route Handlers and pages can still opt into Edge where Next.js supports it there.&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;Next.js 16 way&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;File name&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;proxy.ts&lt;/code&gt; (was &lt;code&gt;middleware.ts&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Old name still works, deprecated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exported function&lt;/td&gt;
&lt;td&gt;&lt;code&gt;export function proxy(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Was &lt;code&gt;export function middleware(...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Node.js only, not configurable&lt;/td&gt;
&lt;td&gt;No Edge option for &lt;code&gt;proxy.ts&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope which paths run it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;export const config = { matcher: [...] }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unchanged from &lt;code&gt;middleware.ts&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrate automatically&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npx @next/codemod@canary middleware-to-proxy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Renames file, function, and config keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renamed config keys&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt; → &lt;code&gt;skipProxyUrlNormalize&lt;/code&gt;; &lt;code&gt;experimental.middlewareClientMaxBodySize&lt;/code&gt; → &lt;code&gt;experimental.proxyClientMaxBodySize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Codemod handles these&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need Edge runtime specifically&lt;/td&gt;
&lt;td&gt;Keep &lt;code&gt;middleware.ts&lt;/code&gt; for now&lt;/td&gt;
&lt;td&gt;Tracked for future removal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect / rewrite / headers API&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NextResponse.next()&lt;/code&gt; / &lt;code&gt;.redirect()&lt;/code&gt; / &lt;code&gt;.rewrite()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Identical to &lt;code&gt;middleware.ts&lt;/code&gt;
&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;// The canonical proxy.ts shape: check, then let through or redirect&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;NextResponse&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;next/server&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="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;next/server&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;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;isAllowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* your boundary check — auth, locale, A/B, etc. */&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAllowed&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matcher&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;/dashboard/:path*&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;h2&gt;
  
  
  🎮 Try it yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/nextjs-weekly-middleware-to-proxy-network-boundary/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;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;proxy.ts&lt;/code&gt; is Next.js 16's rename of &lt;code&gt;middleware.ts&lt;/code&gt; — same job (the network boundary in front of your app), new name, and a fixed Node.js runtime that can't be configured.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;middleware.ts&lt;/code&gt; still runs today, but it's deprecated; the official codemod (&lt;code&gt;npx @next/codemod@canary middleware-to-proxy&lt;/code&gt;) migrates the file, the function name, and the config keys together.&lt;/li&gt;
&lt;li&gt;The Edge runtime isn't gone from Next.js — it's gone specifically from this one boundary file, so don't over-apply the change to Route Handlers or pages.&lt;/li&gt;
&lt;li&gt;Because the boundary now runs on Node.js unconditionally, you can use ordinary Node-only libraries there without an Edge-compatibility audit — but you don't have to change code that already worked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This series has already covered two things &lt;code&gt;proxy.ts&lt;/code&gt; sits in front of: the request eventually reaches &lt;a href="https://dev.to/parsajiravand/nextjs-server-actions-mutations-security-cheat-sheet-b63"&gt;Server Actions and the mutation flow they run&lt;/a&gt;, and whatever renders downstream is shaped by &lt;a href="https://dev.to/parsajiravand/nextjs-cache-components-explained-with-cheat-sheet-55ob"&gt;Cache Components and what streams versus what's cached&lt;/a&gt;. Neither is required reading here, but both make more sense once you know what already ran before them.&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/nextjs-weekly-middleware-to-proxy-network-boundary/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;Your &lt;code&gt;middleware.ts&lt;/code&gt; still works today — but it's running on borrowed time and an assumption about the Edge runtime that no longer holds for new code. Migrate it this week, while the diff is small and the reasoning is fresh, rather than in a rush when the removal notice finally lands. What's the messiest thing your boundary file currently does — and would you trust it to run on Node.js without a second look?&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>nextjs</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Writing JavaScript To Fix `100vh` On Mobile</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:35:37 +0000</pubDate>
      <link>https://dev.to/parsajiravand/stop-writing-javascript-to-fix-100vh-on-mobile-42bk</link>
      <guid>https://dev.to/parsajiravand/stop-writing-javascript-to-fix-100vh-on-mobile-42bk</guid>
      <description>&lt;p&gt;Your hero section is &lt;code&gt;height: 100vh&lt;/code&gt;. Big background image, headline centered, a CTA button pinned near the bottom. You check it in the browser, check it in the iOS simulator, ship it.&lt;/p&gt;

&lt;p&gt;Then the support tickets come in: "the sign-up button is cut off." You open it on your own phone to confirm — and it looks fine. Scroll down an inch, though, and there's a sliver of button peeking out from under where the fold &lt;em&gt;should&lt;/em&gt; be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guess what's different between your simulator and a real phone in someone's hand — before you scroll.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The instinct is to blame the button, not the unit. Add padding, shrink the image, tweak breakpoints. None of it holds, because the box itself is the wrong size — &lt;code&gt;100vh&lt;/code&gt; is taller than what the user can actually see, and no amount of internal spacing fixes a container that's bigger than its own screen.&lt;/p&gt;

&lt;p&gt;So the real fix people land on, for years now, has been JavaScript:&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;setViewportHeight&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;documentElement&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="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--vh&lt;/span&gt;&lt;span class="dl"&gt;"&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.01&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="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;setViewportHeight&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nb"&gt;window&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;resize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setViewportHeight&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 css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--vh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;100&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 been copy-pasted into thousands of codebases as a shrug-emoji workaround — a &lt;code&gt;resize&lt;/code&gt; listener recalculating a custom property, just so CSS can know something the browser already knows. It renders with the &lt;em&gt;wrong&lt;/em&gt; height for one frame before the script runs. It has to be re-wired for every new full-screen element. And on some Android keyboards, "resize" fires in ways that make the hero jump while someone's mid-scroll.&lt;/p&gt;

&lt;p&gt;You're not wrong to reach for it. &lt;code&gt;vh&lt;/code&gt; really doesn't tell you what you think it tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;100vh&lt;/code&gt; actually measures
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes the bug feel random instead of consistent: &lt;code&gt;100vh&lt;/code&gt; isn't measuring "the screen right now." It's measuring the browser's &lt;strong&gt;largest possible viewport&lt;/strong&gt; — the height you'd get if the address bar and toolbar were fully collapsed.&lt;/p&gt;

&lt;p&gt;On iOS Safari, the address bar collapses when you scroll down and reappears when you scroll up or land on a fresh page. &lt;code&gt;100vh&lt;/code&gt; was sized for the collapsed state the whole time — so the moment that bar is visible (which, on a page load, it always is), your "full screen" hero is taller than the actual visible area by exactly the height of that bar. The bottom of your hero, CTA included, sits behind it.&lt;/p&gt;

&lt;p&gt;And for years, Chrome on Android handled the same unit differently — sizing &lt;code&gt;vh&lt;/code&gt; around the &lt;em&gt;smallest&lt;/em&gt; viewport instead, bar included. Two major browsers, one CSS unit, two different answers to "how tall is the screen." That's why this bug never had a clean repro: it depended on the browser, the scroll position, and whether the user had just loaded the page or scrolled once already.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vh&lt;/code&gt; isn't lying. It's answering a question — "how tall is this viewport at its largest?" — that nobody actually asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The units that were built to answer the real question
&lt;/h2&gt;

&lt;p&gt;Since 2022, every major browser engine ships three more precise viewport units, and they turn that whole JavaScript workaround into one CSS declaration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;svh&lt;/code&gt;&lt;/strong&gt; — small viewport height. The height when browser UI (address bar, toolbar) is fully &lt;em&gt;expanded&lt;/em&gt;. This is the safe, stable floor: content sized to &lt;code&gt;svh&lt;/code&gt; always fits, bars showing or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lvh&lt;/code&gt;&lt;/strong&gt; — large viewport height. The height when browser UI is fully &lt;em&gt;collapsed&lt;/em&gt;. This is what &lt;code&gt;vh&lt;/code&gt; was already measuring on iOS Safari.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dvh&lt;/code&gt;&lt;/strong&gt; — dynamic viewport height. Tracks the &lt;em&gt;real, current&lt;/em&gt; visible height, live, recalculating as the address bar shows and hides.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swap the unit, delete the JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* fallback for browsers that don't parse dvh */&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="n"&gt;dvh&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;Browsers that don't recognize &lt;code&gt;dvh&lt;/code&gt; treat that whole declaration as invalid and ignore it — so the &lt;code&gt;100vh&lt;/code&gt; line above it quietly stands as the fallback, no &lt;code&gt;@supports&lt;/code&gt; block required. Everywhere &lt;code&gt;dvh&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; understood, the second line wins and the hero tracks the real viewport, live, as the bar animates in and out. No &lt;code&gt;resize&lt;/code&gt; listener, no custom property, no one-frame flash of the wrong size.&lt;/p&gt;

&lt;p&gt;The same trio exists for width (&lt;code&gt;svw&lt;/code&gt;/&lt;code&gt;lvw&lt;/code&gt;/&lt;code&gt;dvw&lt;/code&gt;) and for logical dimensions (&lt;code&gt;svi&lt;/code&gt;/&lt;code&gt;lvi&lt;/code&gt;/&lt;code&gt;dvi&lt;/code&gt;, &lt;code&gt;svb&lt;/code&gt;/&lt;code&gt;lvb&lt;/code&gt;/&lt;code&gt;dvb&lt;/code&gt;, for writing-mode-aware layouts) — same three questions, different axis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it before you keep reading:&lt;/strong&gt; the playground below simulates a phone's address bar collapsing as you scroll, with three stacked panels — one sized in &lt;code&gt;vh&lt;/code&gt;, one in &lt;code&gt;svh&lt;/code&gt;, one in &lt;code&gt;dvh&lt;/code&gt; — so you can watch all three respond (or fail to respond) to the exact same scroll.&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-dynamic-viewport-units-dvh-svh-lvh/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;
  
  
  Pick the right one, not just the new one
&lt;/h2&gt;

&lt;p&gt;Reaching for &lt;code&gt;dvh&lt;/code&gt; everywhere trades one bug for a subtler one. Because &lt;code&gt;dvh&lt;/code&gt; recalculates &lt;em&gt;while the address bar is mid-animation&lt;/em&gt;, anything sized with it visibly resizes as the user scrolls — a hero that gently grows and shrinks by fifty-odd pixels as the bar slides. For a full-bleed section that's supposed to feel locked to the screen, that's the right behavior. For, say, a sticky modal or a fixed bottom sheet, that same live resize reads as jitter.&lt;/p&gt;

&lt;p&gt;A rough rule that holds up in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dvh&lt;/code&gt;&lt;/strong&gt; for full-bleed sections you want to genuinely hug the visible viewport — landing page heroes, full-screen intro panels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;svh&lt;/code&gt;&lt;/strong&gt; for anything that must never get cut off, even worst-case — a modal's max-height, a "fits on one screen" onboarding step. It's the pessimistic, always-safe number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lvh&lt;/code&gt;&lt;/strong&gt; rarely, when you specifically want the old &lt;code&gt;100vh&lt;/code&gt; behavior on purpose (matching a background that's meant to bleed past the visible edge).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Support is solid enough to reach for today without a polyfill: Safari since 15.4 (March 2022), Chrome and Edge since 108 (December 2022), Firefox since 101 (May 2022). The &lt;code&gt;height: 100vh; height: 100dvh;&lt;/code&gt; stacking pattern above is your fallback for anything older — no feature query needed, because the invalid-property-drop behavior does the work for you.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;100vh&lt;/code&gt; was never broken — it was always precisely, consistently answering "how tall is this viewport with the browser chrome fully out of the way," which is a real answer to a question almost nobody meant to ask. &lt;code&gt;dvh&lt;/code&gt;, &lt;code&gt;svh&lt;/code&gt;, and &lt;code&gt;lvh&lt;/code&gt; let you ask the actual question directly, in CSS, and delete the &lt;code&gt;resize&lt;/code&gt; listener that's been standing in for them.&lt;/p&gt;

&lt;p&gt;If you've got a &lt;code&gt;--vh&lt;/code&gt; custom property hack sitting in a codebase right now — how old is it, and what would it take to rip out?&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-dynamic-viewport-units-dvh-svh-lvh/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>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>React Derived State: Why That useState Is Probably a Bug</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sun, 20 Sep 2026 16:27:06 +0000</pubDate>
      <link>https://dev.to/parsajiravand/react-derived-state-why-that-usestate-is-probably-a-bug-37h0</link>
      <guid>https://dev.to/parsajiravand/react-derived-state-why-that-usestate-is-probably-a-bug-37h0</guid>
      <description>&lt;p&gt;You add a search box to a todo list. &lt;code&gt;todos&lt;/code&gt; is state, &lt;code&gt;filter&lt;/code&gt; is state, and — because the filtered list "depends on" both — you add a third state variable, &lt;code&gt;visibleTodos&lt;/code&gt;, and a &lt;code&gt;useEffect&lt;/code&gt; that recalculates it whenever &lt;code&gt;todos&lt;/code&gt; or &lt;code&gt;filter&lt;/code&gt; changes. It works. Then, months later, someone adds a bulk "complete all" button that updates &lt;code&gt;todos&lt;/code&gt; directly, and the list on screen doesn't change for a beat. No error, no warning — just a stale list until the next keystroke nudges the Effect awake.&lt;/p&gt;

&lt;p&gt;Nothing here is exotic. It's one of the most common bugs in React codebases, and it exists because a value that should have been &lt;em&gt;computed&lt;/em&gt; got stored instead.&lt;/p&gt;

&lt;p&gt;This is episode two of &lt;strong&gt;React Deep Dive&lt;/strong&gt;, on what React itself decides rather than JavaScript wearing a React import. This one is about a decision every component makes constantly and mostly gets right by accident: which values belong in state, and which ones only look like they do.&lt;/p&gt;

&lt;p&gt;This article is written against &lt;strong&gt;React 19.3&lt;/strong&gt; (verified against the React blog and GitHub releases, 19.3.0, published September 9, 2026) and the &lt;strong&gt;React Compiler at 1.0&lt;/strong&gt;, stable since October 2025. Everything here assumes React 19-era function components and hooks.&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;Recognize derived state — a value fully determined by props or other state — versus state that genuinely needs to exist&lt;/li&gt;
&lt;li&gt;Explain, precisely, why syncing a derived value with &lt;code&gt;useEffect&lt;/code&gt; + &lt;code&gt;setState&lt;/code&gt; costs an extra render and can drift&lt;/li&gt;
&lt;li&gt;Replace that pattern with a plain calculation during render, and know when to reach for &lt;code&gt;useMemo&lt;/code&gt; instead&lt;/li&gt;
&lt;li&gt;Handle the harder case — resetting or adjusting state when a prop changes — without an Effect&lt;/li&gt;
&lt;li&gt;Tell the difference between "derived from what I already have" and "genuinely new information," which is the actual boundary&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You've written function components with &lt;code&gt;useState&lt;/code&gt; and &lt;code&gt;useEffect&lt;/code&gt;, and you've shipped at least one bug where two pieces of state disagreed with each other. No prior knowledge of the React Compiler is assumed.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: a list that lags behind its own data&lt;/li&gt;
&lt;li&gt;The mental model: state is memory, render is a formula&lt;/li&gt;
&lt;li&gt;Stage 1: the naive fix and why it still isn't right&lt;/li&gt;
&lt;li&gt;Stage 2: delete the state, keep the value&lt;/li&gt;
&lt;li&gt;Stage 3: when the calculation is actually expensive&lt;/li&gt;
&lt;li&gt;Stage 4: the harder case — resetting state when a prop changes&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices: the actual test&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a list that lags behind its own data
&lt;/h2&gt;

&lt;p&gt;Here's the todo list from the opening, written the way it tends to get written the first time:&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;TodoList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;todos&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="s2"&gt;all&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;visibleTodos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setVisibleTodos&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="nx"&gt;todos&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="nf"&gt;setVisibleTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;todos&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="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;FilterButtons&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="nx"&gt;setFilter&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;ul&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;visibleTodos&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;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;li&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;/&lt;/span&gt;&lt;span class="nt"&gt;ul&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 runs. It even looks reasonable — &lt;code&gt;visibleTodos&lt;/code&gt; "depends on" &lt;code&gt;todos&lt;/code&gt; and &lt;code&gt;filter&lt;/code&gt;, so it lives in an Effect that watches both. But walk through what actually happens on a single click of a filter button:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;setFilter("done")&lt;/code&gt; schedules a render. &lt;code&gt;TodoList&lt;/code&gt; re-renders with the &lt;em&gt;new&lt;/em&gt; &lt;code&gt;filter&lt;/code&gt; but the &lt;em&gt;old&lt;/em&gt; &lt;code&gt;visibleTodos&lt;/code&gt; — React hasn't run your Effect yet, because Effects run &lt;strong&gt;after&lt;/strong&gt; the DOM commits.&lt;/li&gt;
&lt;li&gt;The user briefly sees the wrong list (all todos, not just the done ones), for one paint.&lt;/li&gt;
&lt;li&gt;The Effect then runs, calls &lt;code&gt;setVisibleTodos&lt;/code&gt;, and schedules a &lt;strong&gt;second&lt;/strong&gt; render.&lt;/li&gt;
&lt;li&gt;React renders again, this time with the correct filtered list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's two full render passes and one commit doing visible work for a value you could have had correct on the first pass. And the bulk "complete all" bug from the intro is the same mechanism from the other direction: something mutates &lt;code&gt;todos&lt;/code&gt; through a path that doesn't also re-run this specific Effect's mental model correctly, or a later render reads &lt;code&gt;visibleTodos&lt;/code&gt; before the Effect catches up, and the two state variables disagree.&lt;/p&gt;

&lt;p&gt;None of this is a React bug. React is doing exactly what you asked: keep two separate pieces of memory, and use an Effect to keep the second one following the first. The bug is that &lt;code&gt;visibleTodos&lt;/code&gt; was never independent information — it was a formula wearing state's clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: state is memory, render is a formula
&lt;/h2&gt;

&lt;p&gt;Split every value your component touches into two categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt; is memory. It's the only thing React can't reconstruct on its own — user input, a value from a request, anything genuinely new that arrived from outside this render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A derived value&lt;/strong&gt; is anything you could recompute, right now, from state and props you already have. It isn't information; it's a formula over information.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; if you can write &lt;code&gt;const x = f(props, otherState)&lt;/code&gt; and get the right answer every time, &lt;code&gt;x&lt;/code&gt; was never state — it's a calculation, and calculations belong in the render body, not in a &lt;code&gt;useState&lt;/code&gt;/&lt;code&gt;useEffect&lt;/code&gt; pair. An Effect exists to synchronize your component with something &lt;strong&gt;outside React&lt;/strong&gt; — the DOM, a subscription, a network request, &lt;code&gt;document.title&lt;/code&gt;. Using one to copy one piece of React state into another piece of React state is React talking to itself through a detour, and the detour is where the extra render and the drift both come from.&lt;/p&gt;

&lt;p&gt;This reframes &lt;code&gt;useEffect&lt;/code&gt; itself: it isn't "the place derived stuff goes," it's "the place &lt;em&gt;synchronization with the outside world&lt;/em&gt; goes." A value computed from props and state was never outside anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: the naive fix and why it still isn't right
&lt;/h2&gt;

&lt;p&gt;A common first correction is to memoize inside the Effect, or to add a guard so it "only runs when needed":&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="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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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;next&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;visibleTodos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setVisibleTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;todos&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; this treats the symptom (an extra render) without touching the cause (a second copy of information that has to be kept honest). The stale-paint flash from step 2 above is still there — the Effect still runs after the commit, not before it — and you've added a comparison that has to be maintained forever. The state was the mistake; no amount of guarding the Effect fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: delete the state, keep the value
&lt;/h2&gt;

&lt;p&gt;The actual fix removes code:&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;TodoList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;todos&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="s2"&gt;all&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;visibleTodos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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;FilterButtons&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="nx"&gt;setFilter&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;ul&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;visibleTodos&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;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;li&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;/&lt;/span&gt;&lt;span class="nt"&gt;ul&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;No &lt;code&gt;visibleTodos&lt;/code&gt; state, no Effect, no second render, no drift — because there is only one piece of information (&lt;code&gt;todos&lt;/code&gt; and &lt;code&gt;filter&lt;/code&gt;) and one formula over it. Click a filter button now, and &lt;code&gt;TodoList&lt;/code&gt; renders exactly once, with the correct list, because the correct list was never anything but &lt;code&gt;todos&lt;/code&gt; and &lt;code&gt;filter&lt;/code&gt; combined.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; a value that's recomputed on every render is not "wasted work" by default — rendering is supposed to be cheap and pure. Reach for a second render only when React genuinely needs one; a plain &lt;code&gt;const&lt;/code&gt; inside the component body isn't a render, it's a step within the one you're already doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: when the calculation is actually expensive
&lt;/h2&gt;

&lt;p&gt;Sometimes the formula really is costly — sorting or filtering thousands of rows, for instance — and recomputing it on every render (including ones triggered by something unrelated, like typing in an unrelated text field on the same component) is wasteful. That's what &lt;code&gt;useMemo&lt;/code&gt; is for:&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;const&lt;/span&gt; &lt;span class="nx"&gt;visibleTodos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;todos&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useMemo&lt;/code&gt; still computes the value &lt;strong&gt;during render&lt;/strong&gt;, synchronously, before anything paints — it just skips redoing the work if &lt;code&gt;todos&lt;/code&gt; and &lt;code&gt;filter&lt;/code&gt; are unchanged since last time. It is a performance optimization, not a place to put a second copy of the answer, and it produces no second commit the way the Effect version did.&lt;/p&gt;

&lt;p&gt;Episode one of this series (&lt;a href="https://dev.to/parsajiravand/react-compiler-10-what-usememo-you-can-delete-hgm"&gt;React Compiler 1.0: What &lt;code&gt;useMemo&lt;/code&gt; You Can Delete&lt;/a&gt;) covers the other side of this: with the Compiler enabled, this specific &lt;code&gt;useMemo&lt;/code&gt; is usually one you no longer have to write by hand — the compiler memoizes it for you. What it will never do is turn a &lt;code&gt;useEffect&lt;/code&gt;-plus-&lt;code&gt;setState&lt;/code&gt; pair back into a derived value; that rewrite is a design decision, not something a compiler can safely infer, because it can't know your Effect wasn't also doing something with genuine side effects on the outside world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: the harder case — resetting state when a prop changes
&lt;/h2&gt;

&lt;p&gt;Sometimes what looks like "syncing derived state" is really about a prop change that should reset unrelated state — a &lt;code&gt;&amp;lt;ProfilePanel userId&amp;gt;&lt;/code&gt; where switching users should clear a draft comment, for instance. The Effect-shaped instinct is the same trap:&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="c1"&gt;// Don't do this&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProfilePanel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&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;comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setComment&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="nf"&gt;setComment&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="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This has the identical problem as Stage 1: one render with the stale comment, then an Effect-triggered second render that clears it. The fix here isn't a calculation — &lt;code&gt;comment&lt;/code&gt; genuinely is state, freely typed by the user, not derivable from anything — the fix is telling React this is a &lt;strong&gt;different instance&lt;/strong&gt; of the component, which is a subject the first episode of this series covered in depth: giving the component a &lt;code&gt;key&lt;/code&gt;.&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProfilePanel&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;userId&lt;/code&gt; changes, React doesn't update the existing &lt;code&gt;ProfilePanel&lt;/code&gt; instance — it discards it and mounts a fresh one, with &lt;code&gt;comment&lt;/code&gt; back at its initial value, in the &lt;em&gt;same&lt;/em&gt; render, no Effect involved. See &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; for exactly how React decides between updating an instance and replacing it. The two bugs in this article and that one are the same shape from two directions: this one is about values you shouldn't have kept as separate memory at all; that one is about memory you correctly kept, but attached to the wrong lifetime.&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-derived-state-bug/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;
  
  
  Edge cases and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adjusting only &lt;em&gt;some&lt;/em&gt; state when a prop changes.&lt;/strong&gt; If a &lt;code&gt;key&lt;/code&gt; reset would throw away too much (say, only one field should clear, the rest should survive), React's own guidance is to compute that one field during render by comparing the current prop against a &lt;code&gt;previous&lt;/code&gt;-value stored in state — a narrow, deliberate escape hatch, not a general pattern, and one that reads oddly enough that it deserves a comment explaining why it isn't an Effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetched data is not derived state.&lt;/strong&gt; &lt;code&gt;todos&lt;/code&gt; arriving from an API is genuinely new information your component couldn't calculate on its own — that's real state (or better, a request-lifecycle tool that isn't plain &lt;code&gt;useState&lt;/code&gt;). The rule in this article is about values computed &lt;em&gt;from&lt;/em&gt; data you already hold, not the data itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived values that also need to survive a remount.&lt;/strong&gt; If a formula's result should persist across a &lt;code&gt;key&lt;/code&gt; change on purpose, it can't be "just a calculation" anymore — you've described state, and that's fine; just be honest that it is one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useMemo&lt;/code&gt; is not a correctness tool.&lt;/strong&gt; It changes &lt;em&gt;when&lt;/em&gt; the calculation reruns for performance, never &lt;em&gt;what&lt;/em&gt; the calculation returns. Never rely on &lt;code&gt;useMemo&lt;/code&gt; to skip work that has an externally visible side effect — that's what &lt;code&gt;useEffect&lt;/code&gt; is for, and the two aren't interchangeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context values built from derived state.&lt;/strong&gt; A &lt;code&gt;useMemo&lt;/code&gt;-wrapped object passed through &lt;code&gt;Context.Provider&lt;/code&gt; is a very common and correct use of memoization — it stops every consumer from re-rendering just because the provider re-rendered with a fresh object literal. That's a real performance concern this pattern solves; it's a different subject from this article's bug, not evidence that all derived values need memoizing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best practices: the actual test
&lt;/h2&gt;

&lt;p&gt;Ask one question, in this order, every time a new value shows up in a component: &lt;strong&gt;could I compute this, right now, from props and state I already have?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes, and it's cheap&lt;/strong&gt; → a plain &lt;code&gt;const&lt;/code&gt; in the render body. No hook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yes, but it's measurably expensive&lt;/strong&gt; → &lt;code&gt;useMemo&lt;/code&gt;, still computed during render, still zero extra commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No, this value is genuinely new information from outside React&lt;/strong&gt; (user input, a fetch response, a timer tick) → it's real state, and &lt;code&gt;useEffect&lt;/code&gt; is the right tool if it also needs to synchronize with something outside React.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A prop change should reset an entire component's state&lt;/strong&gt; → a &lt;code&gt;key&lt;/code&gt;, not an Effect that calls several &lt;code&gt;setState&lt;/code&gt;s in a row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you ever catch yourself writing a &lt;code&gt;useEffect&lt;/code&gt; whose only job is calling &lt;code&gt;setState&lt;/code&gt; with a value derived purely from props and other state already in scope, that Effect is the bug, not the fix.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is every &lt;code&gt;useState&lt;/code&gt; that depends on props automatically derived state?
&lt;/h3&gt;

&lt;p&gt;Not automatically — the test is whether the &lt;em&gt;value&lt;/em&gt; is fully determined by props and other state, not whether it merely reads them. A text input's &lt;code&gt;value&lt;/code&gt; reads a &lt;code&gt;defaultValue&lt;/code&gt; prop once, then holds independent user edits; that's real state that happened to be seeded from a prop, not a formula recomputed from it every render.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does using &lt;code&gt;useMemo&lt;/code&gt; mean a value isn't derived state anymore?
&lt;/h3&gt;

&lt;p&gt;No — &lt;code&gt;useMemo&lt;/code&gt; is still deriving the value during render from the same inputs; it only caches the result between renders with unchanged inputs. A &lt;code&gt;useMemo&lt;/code&gt;'d value and a plain &lt;code&gt;const&lt;/code&gt; are the same &lt;em&gt;kind&lt;/em&gt; of thing (a formula), differing only in whether the formula's cost justifies caching it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the Effect-based version actually visibly flash the wrong content?
&lt;/h3&gt;

&lt;p&gt;Because Effects run after the browser has painted the render's output, not before it. The render with stale derived state is not skipped or invisible — it's a real commit the user can see, however briefly, before the Effect's &lt;code&gt;setState&lt;/code&gt; triggers the corrected one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the React Compiler make this whole article unnecessary?
&lt;/h3&gt;

&lt;p&gt;No. The Compiler (stable at 1.0) automates &lt;em&gt;memoizing&lt;/em&gt; values you already compute during render — it will not rewrite a &lt;code&gt;useEffect&lt;/code&gt;-plus-&lt;code&gt;setState&lt;/code&gt; pair into a derived calculation for you, because it can't safely know your Effect isn't also doing something with a real side effect. Removing the state is still a decision you make.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the actual bug, in one sentence, if I never fix this?
&lt;/h3&gt;

&lt;p&gt;Two variables that are supposed to always agree occasionally won't, because one is memory and the other is a stale copy of a formula over that memory — and every real production codebase eventually hits the sequence of updates that makes them disagree.&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-derived-state-bug/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;
  
  
  Cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What to write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Value fully computable from current props/state&lt;/td&gt;
&lt;td&gt;Derived value&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;const x = f(props, state)&lt;/code&gt; — no hook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, but the computation is measurably slow&lt;/td&gt;
&lt;td&gt;Derived value, cached&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useMemo(() =&amp;gt; f(...), [deps])&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Genuinely new info from outside React (input, fetch, timer)&lt;/td&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;useState&lt;/code&gt;, updated by the event/callback that produced it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A prop change should reset a whole component's state&lt;/td&gt;
&lt;td&gt;Identity change&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;Child key={propValue} /&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A prop change should reset only one field, rest must survive&lt;/td&gt;
&lt;td&gt;Narrow adjustment&lt;/td&gt;
&lt;td&gt;Compare a stored &lt;code&gt;previous&lt;/code&gt; value during render — last resort, comment it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object/array passed through Context that would otherwise churn&lt;/td&gt;
&lt;td&gt;Derived value, cached for consumers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;useMemo&lt;/code&gt; around the Provider's &lt;code&gt;value&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The one-line test, every time you reach for useState:&lt;/span&gt;
&lt;span class="c1"&gt;// "Could I calculate this from props/state I already have?"&lt;/span&gt;
&lt;span class="c1"&gt;// Yes  -&amp;gt; const, or useMemo if it's expensive. No Effect.&lt;/span&gt;
&lt;span class="c1"&gt;// No   -&amp;gt; it's real state. useEffect only if it must sync with&lt;/span&gt;
&lt;span class="c1"&gt;//         something outside React (DOM, subscription, network).&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;If a value can be computed from props and state you already have, it isn't state — it's a formula, and it belongs in the render body.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;useEffect&lt;/code&gt; that calls &lt;code&gt;setState&lt;/code&gt; with a derived value costs a real extra render (Effects run after the commit) and creates two copies of one fact, which can drift.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useMemo&lt;/code&gt; caches a derived calculation for performance; it never changes what the calculation returns and never replaces the decision to remove unnecessary state in the first place.&lt;/li&gt;
&lt;li&gt;When a prop change should reset a component's state entirely, use &lt;code&gt;key&lt;/code&gt;, not an Effect — it's a single render, not two.&lt;/li&gt;
&lt;li&gt;The React Compiler automates memoizing derived values; it does not and cannot decide for you that a state variable shouldn't have existed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Back to that todo list
&lt;/h2&gt;

&lt;p&gt;The fix for the opening bug was never a smarter Effect — it was noticing that &lt;code&gt;visibleTodos&lt;/code&gt; had no information in it that &lt;code&gt;todos&lt;/code&gt; and &lt;code&gt;filter&lt;/code&gt; didn't already have. Delete the state, keep the formula, and the "complete all" bug and the one-paint flash both disappear, because there was never a second fact to get out of sync in the first place.&lt;/p&gt;

&lt;p&gt;Next time you write &lt;code&gt;useEffect(() =&amp;gt; setSomething(...), [dep])&lt;/code&gt;, try the one-line test from this article before you commit it: could &lt;code&gt;something&lt;/code&gt; just be a &lt;code&gt;const&lt;/code&gt;? If yes, you've probably found a bug before your users did.&lt;/p&gt;

&lt;p&gt;What's the strangest derived-state bug you've shipped — and how long did it take to notice? Drop it in the comments.&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>react</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Browser Is Rejecting Every Drop On Purpose</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sun, 20 Sep 2026 16:26:35 +0000</pubDate>
      <link>https://dev.to/parsajiravand/your-browser-is-rejecting-every-drop-on-purpose-4jji</link>
      <guid>https://dev.to/parsajiravand/your-browser-is-rejecting-every-drop-on-purpose-4jji</guid>
      <description>&lt;p&gt;A teammate pings you: the drag-and-drop on the Kanban board doesn't work. You can pick a card up — it goes translucent, follows the cursor, everything looks right — but dropping it on a column does nothing. No error. No red text in the console. The card just snaps back like the drop never happened.&lt;/p&gt;

&lt;p&gt;You open the code. The listener is right there:&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;dropzone&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;drop&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="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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;dropzone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&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="nf"&gt;getElementById&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right element. Right event name, spelled correctly. &lt;code&gt;dataTransfer.setData&lt;/code&gt; was called in &lt;code&gt;dragstart&lt;/code&gt; with the same key. By every reasonable check, this should work. It doesn't — and it isn't going to, no matter how long you stare at this exact function, because the bug isn't in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guess before you scroll:&lt;/strong&gt; the fix isn't in the &lt;code&gt;drop&lt;/code&gt; handler at all. It's a handler that isn't there yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious things you'd check first — and why they don't help
&lt;/h2&gt;

&lt;p&gt;The instinct is to suspect &lt;code&gt;draggable&lt;/code&gt;. Divs aren't draggable by default the way images and links are, so you add &lt;code&gt;draggable="true"&lt;/code&gt; to the card. Now &lt;code&gt;dragstart&lt;/code&gt; fires, the ghost image appears, everything about &lt;em&gt;picking up&lt;/em&gt; the card works exactly as it should.&lt;/p&gt;

&lt;p&gt;Dropping it still does nothing.&lt;/p&gt;

&lt;p&gt;Next instinct: maybe &lt;code&gt;dataTransfer&lt;/code&gt; needs a different MIME key, or &lt;code&gt;setData&lt;/code&gt;/&lt;code&gt;getData&lt;/code&gt; need to match more exactly. You double, triple check the strings. They already matched. That was never the problem either.&lt;/p&gt;

&lt;p&gt;Here's the part that's easy to miss: &lt;strong&gt;the browser doesn't consider your &lt;code&gt;dropzone&lt;/code&gt; a valid place to drop anything, and it never told you.&lt;/strong&gt; Its default behavior for &lt;em&gt;any&lt;/em&gt; element receiving a drag is to refuse the drop — the same way it refuses to run a &lt;code&gt;submit&lt;/code&gt; button's default action once you call &lt;code&gt;preventDefault()&lt;/code&gt; on the form's &lt;code&gt;submit&lt;/code&gt; event, except inverted: here, doing nothing &lt;em&gt;is&lt;/em&gt; the default, and you have to actively cancel it to unlock the alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one handler nobody adds because its name gives nothing away
&lt;/h2&gt;

&lt;p&gt;The event that carries this default is &lt;code&gt;dragover&lt;/code&gt; — the one that fires repeatedly while the dragged item hovers over your element, several times a second, for as long as the drag continues. It sounds like a bookkeeping event, the kind you'd only need for a hover highlight. It is actually the gate:&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;dropzone&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;dragover&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;-- this is the whole fix&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;dataTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dropEffect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;move&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;Add that one handler — nothing fancier, no new logic in &lt;code&gt;drop&lt;/code&gt; — and the exact same &lt;code&gt;drop&lt;/code&gt; listener from before starts firing. The browser was checking, on every single &lt;code&gt;dragover&lt;/code&gt; tick, whether &lt;em&gt;something&lt;/em&gt; had canceled its "refuse this drop" default. Nothing had. So by the time the cursor was released, there was nothing to drop &lt;em&gt;onto&lt;/em&gt;, and the &lt;code&gt;drop&lt;/code&gt; event was simply never dispatched. Not swallowed, not caught somewhere — never sent at all.&lt;/p&gt;

&lt;p&gt;Most tutorials also cancel &lt;code&gt;dragenter&lt;/code&gt;'s default alongside &lt;code&gt;dragover&lt;/code&gt;'s, and it's worth doing — it lets you flip a highlight class the instant the drag enters instead of waiting for the first &lt;code&gt;dragover&lt;/code&gt; tick, and it papers over a few older-Safari quirks. But &lt;code&gt;dragover&lt;/code&gt; is the one that's load-bearing. Cancel only &lt;code&gt;dragenter&lt;/code&gt; and skip &lt;code&gt;dragover&lt;/code&gt;, and you're back to the same silent nothing you started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second landmine, right behind the first
&lt;/h2&gt;

&lt;p&gt;Fix that, and a Kanban board's columns usually need a hover highlight — "you're currently over a valid drop zone" — added and removed as the drag moves. The naive version:&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;dropzone&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;dragenter&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="nx"&gt;dropzone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;dropzone&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;dragleave&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="nx"&gt;dropzone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over&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;This flickers if the drop zone has &lt;em&gt;any&lt;/em&gt; child elements — a card, a label, an icon — because &lt;code&gt;dragenter&lt;/code&gt;/&lt;code&gt;dragleave&lt;/code&gt; fire per element, and they bubble. Move the cursor from the column onto a card sitting inside it, and the browser fires &lt;code&gt;dragleave&lt;/code&gt; on the column (you left it for the card) immediately followed by &lt;code&gt;dragenter&lt;/code&gt; on the card. Your highlight class turns off and back on, several times a second, every time the cursor crosses a child boundary. It isn't a bug in your logic — it's the DOM correctly reporting boundary crossings you didn't think to account for.&lt;/p&gt;

&lt;p&gt;The fix is to stop trusting "did I leave" and check "did I leave into something outside this element," using the leave event's &lt;code&gt;relatedTarget&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dropzone&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;dragleave&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dropzone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&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;relatedTarget&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;dropzone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over&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;h2&gt;
  
  
  🎮 Try it yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/drag-drop-api-dragover-preventdefault-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;
  
  
  The data you can't read yet, either
&lt;/h2&gt;

&lt;p&gt;One more surprise waits for anyone who tries to log the dragged payload early, to debug the two problems above: call &lt;code&gt;e.dataTransfer.getData("text/plain")&lt;/code&gt; inside &lt;code&gt;dragover&lt;/code&gt; or &lt;code&gt;dragenter&lt;/code&gt;, and you get back an empty string — even though &lt;code&gt;dataTransfer.types&lt;/code&gt; correctly lists &lt;code&gt;"text/plain"&lt;/code&gt; as available, and even though the exact same call works fine one line later, inside &lt;code&gt;drop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's not a race condition and it's not something you configured. The drag data store runs in what the spec calls "protected mode" for the whole drag except the &lt;code&gt;dragstart&lt;/code&gt; moment (when it's writable) and the &lt;code&gt;drop&lt;/code&gt; moment (when it's finally readable) — a deliberate guard so that a page you're merely dragging &lt;em&gt;over&lt;/em&gt;, which never receives the drop, can't read what you're carrying. If you need to know what's being dragged before the drop — to decide, say, whether this drop zone should even light up for this drag — read &lt;code&gt;dataTransfer.types&lt;/code&gt; (always available) instead of the data itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this doesn't reach
&lt;/h2&gt;

&lt;p&gt;All of this — &lt;code&gt;dragstart&lt;/code&gt;, &lt;code&gt;dragover&lt;/code&gt;, &lt;code&gt;drop&lt;/code&gt;, the whole &lt;code&gt;dataTransfer&lt;/code&gt; dance — is a mouse-and-desktop story. No major mobile browser fires these events for a finger drag; the touch input model and the HTML Drag and Drop spec were never wired together. If your Kanban board needs to be reorderable on a phone, this API isn't the one that gets you there — you're reaching for Pointer Events with your own hit-testing, or a library like SortableJS that's already done that work. Ship native drag-and-drop as a desktop enhancement, not the only way in.&lt;/p&gt;

&lt;p&gt;The teammate's bug report wasn't wrong, and neither was their code. It was missing one handler whose entire job is invisible until you know to look for it — the browser was waiting, tick after tick, for a &lt;code&gt;preventDefault()&lt;/code&gt; call that never came, and nothing about a missing &lt;code&gt;drop&lt;/code&gt; event tells you that's what's missing.&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/drag-drop-api-dragover-preventdefault-trap/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;Go check your own drop zones. If you've got a &lt;code&gt;drop&lt;/code&gt; listener with no &lt;code&gt;dragover&lt;/code&gt; listener next to it, it's not broken by accident — it's working exactly as specified, refusing every drop on purpose. What's the last time a "the listener's just not firing" bug turned out to be a default you needed to cancel somewhere else entirely?&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>html</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Nuxt Hydration Mismatch: Why It Happens and How to Fix It</title>
      <dc:creator>Parsa Jiravand</dc:creator>
      <pubDate>Sun, 20 Sep 2026 16:26:04 +0000</pubDate>
      <link>https://dev.to/parsajiravand/nuxt-hydration-mismatch-why-it-happens-and-how-to-fix-it-5b7i</link>
      <guid>https://dev.to/parsajiravand/nuxt-hydration-mismatch-why-it-happens-and-how-to-fix-it-5b7i</guid>
      <description>&lt;p&gt;Your Nuxt page looks perfect. "View Source" shows clean, fully-rendered HTML — the hero text, the product price, the footer, all there before a single line of JavaScript ran. Then the client bundle finishes loading, and the console lights up: &lt;code&gt;[Vue warn]: Hydration text mismatch&lt;/code&gt;. Sometimes it's cosmetic — a number flickers and settles. Sometimes it's worse: a button the user already clicked stops responding, because Vue just tore out the DOM node it was attached to and built a new one.&lt;/p&gt;

&lt;p&gt;This is a hydration mismatch, and it's arguably the most &lt;em&gt;Nuxt-specific&lt;/em&gt; bug you'll ever debug. It has nothing to do with your logic being wrong in the way a typo is wrong — your component can be perfectly correct JavaScript and still cause one, because the bug isn't in what you wrote, it's in the fact that Nuxt runs what you wrote &lt;strong&gt;twice, in two different places&lt;/strong&gt;, and bets your app's interactivity on both runs agreeing.&lt;/p&gt;

&lt;p&gt;This article is written against &lt;strong&gt;Nuxt 4.x&lt;/strong&gt; (verified against the v4.5 release line, August 2026), using the Composition API, auto-imports, and the &lt;code&gt;app/&lt;/code&gt; directory convention Nuxt 4 defaults to. Everything here also applies to Nuxt 3's &lt;code&gt;compatibilityVersion: 4&lt;/code&gt; mode.&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 "hydration" means in Nuxt and why a mismatch happens&lt;/li&gt;
&lt;li&gt;Recognize the handful of code patterns that reliably cause one&lt;/li&gt;
&lt;li&gt;Pick the right fix — &lt;code&gt;onMounted&lt;/code&gt;, &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt;, or &lt;code&gt;data-allow-mismatch&lt;/code&gt; — for each situation&lt;/li&gt;
&lt;li&gt;Read a hydration warning and know which line of your code to blame&lt;/li&gt;
&lt;li&gt;Avoid the "fix" that looks reasonable but guarantees a mismatch every time&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You've built at least one Nuxt page with &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; and know roughly what server-side rendering means (the server sends back real HTML instead of an empty &lt;code&gt;&amp;lt;div id="app"&amp;gt;&lt;/code&gt;). You don't need prior SSR debugging experience — that's the point of this article.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem: a page that's "correct" and still breaks&lt;/li&gt;
&lt;li&gt;The mental model: two renders, one DOM&lt;/li&gt;
&lt;li&gt;Fixing it, stage by stage&lt;/li&gt;
&lt;li&gt;Edge cases and gotchas&lt;/li&gt;
&lt;li&gt;Best practices&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;li&gt;Cheat sheet&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem: a page that's "correct" and still breaks
&lt;/h2&gt;

&lt;p&gt;Say you're building a "tip of the day" widget. It's a plain computed value, no fetch, no state management — about as simple as a Vue component gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Use useAsyncData for anything that fetches.&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;Auto-imports save you the import line, not the thinking.&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;Nitro is just Node under the hood.&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;const&lt;/span&gt; &lt;span class="nx"&gt;tip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIPS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;TIPS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="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;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Tip of the day: &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;tip&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&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;Nothing here looks wrong. It compiles, it runs, &lt;code&gt;npm run dev&lt;/code&gt; shows a tip. But open the browser console and you'll see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Vue warn]: Hydration text mismatch:
- Server rendered:  Tip of the day: Nitro is just Node under the hood.
- Client rendered:  Tip of the day: Use useAsyncData for anything that fetches.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing crashed. The page still works. But the text the user saw for a split second — the one baked into the HTML the server sent — silently got replaced by a different one the instant the JavaScript took over. If that "tip" were a price, a username, or which item was in stock, this wouldn't be a curiosity, it would be a bug report.&lt;/p&gt;

&lt;p&gt;The same failure mode shows up with &lt;code&gt;new Date()&lt;/code&gt;, with &lt;code&gt;window.innerWidth&lt;/code&gt;, with anything read from &lt;code&gt;localStorage&lt;/code&gt; inside the component's render path. The common thread: the value depends on &lt;em&gt;where&lt;/em&gt; the code runs, and Nuxt runs your component in two different places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: two renders, one DOM
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt; Nuxt doesn't render your app once — it renders the same component tree twice, in two different environments, and then asks the second render to &lt;em&gt;adopt&lt;/em&gt; the DOM the first render already produced, instead of rebuilding it from scratch.&lt;/p&gt;

&lt;p&gt;Here's the sequence for a single page request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A request hits your server. Nitro runs your Vue app in Node — no browser, no DOM — and walks your components to produce a plain HTML string, plus a serialized &lt;strong&gt;payload&lt;/strong&gt;: the results of every &lt;code&gt;useAsyncData&lt;/code&gt;/&lt;code&gt;useFetch&lt;/code&gt; call and every &lt;code&gt;useState&lt;/code&gt;, embedded in the page as a &lt;code&gt;&amp;lt;script id="__NUXT_DATA__"&amp;gt;&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;The browser receives that HTML and paints it immediately. This is the entire point of SSR — the user sees real content before a single byte of your JavaScript bundle has downloaded.&lt;/li&gt;
&lt;li&gt;The client bundle downloads and boots the &lt;em&gt;same&lt;/em&gt; Vue app, client-side. But instead of creating new DOM nodes the way a client-only SPA would, it runs in &lt;strong&gt;hydration mode&lt;/strong&gt;: it walks the existing DOM the server produced, node by node, and attaches reactivity and event listeners to what's already there, reading the payload from step 1 so it doesn't have to re-fetch data the server already fetched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hydration is a &lt;em&gt;reconciliation&lt;/em&gt;, not a second render from scratch — and reconciliation assumes the two renders agree. When they do, hydration is invisible: the DOM stays exactly as the server drew it, listeners attach, the page becomes interactive. When they don't, Vue has two options depending on how badly they disagree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A text or attribute mismatch&lt;/strong&gt; (a &lt;code&gt;{{ tip }}&lt;/code&gt; that resolved differently, a class that differs): Vue patches just that value in place and — in development only — logs a warning. Production builds do this silently, which is why a mismatch can ship for weeks before anyone notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A structural mismatch&lt;/strong&gt; (a different tag, a different number of children — the kind you get from &lt;code&gt;v-if&lt;/code&gt; branching differently on each side): Vue can't patch that in place. It throws away the mismatched subtree and re-renders it entirely client-side. That's real, visible re-work, and if a user had already interacted with something inside that subtree, the element they clicked no longer exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The payload exists specifically so that data &lt;em&gt;is&lt;/em&gt; safe across hydration — &lt;code&gt;useAsyncData&lt;/code&gt;, &lt;code&gt;useFetch&lt;/code&gt;, and &lt;code&gt;useState&lt;/code&gt; all serialize their results, so the client reads the exact value the server used instead of recomputing it. (If you've read the &lt;a href="https://dev.to/parsajiravand/useasyncdata-keys-in-nuxt-caching-dedupe-the-sharing-bug-el1"&gt;earlier episode on &lt;code&gt;useAsyncData&lt;/code&gt; keys and dedupe&lt;/a&gt;, this is the same payload that makes dedupe possible — it's doing double duty.) The danger is everything &lt;em&gt;outside&lt;/em&gt; that mechanism: any value your template reads that isn't backed by &lt;code&gt;useState&lt;/code&gt;/&lt;code&gt;useAsyncData&lt;/code&gt; and isn't guaranteed identical on both sides — &lt;code&gt;Math.random()&lt;/code&gt;, &lt;code&gt;Date.now()&lt;/code&gt;, &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;navigator&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt; — is a mismatch waiting to happen, because nothing carries it across the server→client boundary for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it, stage by stage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stage 1: defer the value with &lt;code&gt;onMounted&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The tip-of-the-day bug and the "current time" bug are the same shape: a value that's &lt;em&gt;legitimately&lt;/em&gt; allowed to differ per visitor, rendered directly during setup. The fix is to give the template a stable, server-safe default, and only fill in the real value once you're certain you're client-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMounted&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&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;TIPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Use useAsyncData for anything that fetches.&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;…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIPS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;TIPS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="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;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Tip of the day: &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;tip&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Loading…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;onMounted&lt;/code&gt; runs only after hydration has already completed successfully. Anything it writes is a normal, client-only reactive update — Vue never has to reconcile it against server HTML, because by the time it runs, hydration is already done.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: skip SSR entirely with &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Some content isn't "slightly different" between server and client — it can't exist on the server at all. A chart that measures its container's pixel width, a widget that reads &lt;code&gt;localStorage&lt;/code&gt;, a third-party embed that expects &lt;code&gt;window&lt;/code&gt;. For those, don't try to make the server render &lt;em&gt;something&lt;/em&gt; — tell Nuxt not to render it there in the first place. &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; is auto-imported and does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ClientOnly&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;UserLocalClock&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;#fallback&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"clock-placeholder"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;--:--&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ClientOnly&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default slot never runs on the server. The &lt;code&gt;#fallback&lt;/code&gt; slot renders there instead (useful for reserving layout space so nothing jumps), and the moment the component mounts client-side, Nuxt swaps the fallback for the real content — created fresh, never hydrated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; doesn't resolve a mismatch — it removes the possibility of one, because nothing inside it is ever compared between two renders. There's only ever one render, on the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: the branch that looks like a fix but isn't
&lt;/h3&gt;

&lt;p&gt;It's tempting to reach for Nuxt's environment flags — &lt;code&gt;import.meta.server&lt;/code&gt; / &lt;code&gt;import.meta.client&lt;/code&gt; (the modern replacement for the older &lt;code&gt;process.server&lt;/code&gt; / &lt;code&gt;process.client&lt;/code&gt;) — and branch your template directly on them:&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;!-- Don't do this --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"import.meta.client"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Client-rendered content&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;v-else&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Server-rendered content&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&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;This guarantees a structural mismatch, every single time. On the server, &lt;code&gt;import.meta.server&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, so the server emits the &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; from the &lt;code&gt;v-else&lt;/code&gt; branch. On the client, during hydration, &lt;code&gt;import.meta.client&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, so Vue's hydration walk expects the &lt;code&gt;v-if&lt;/code&gt; branch — a different &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; than the one actually sitting in the DOM. Vue can't reconcile two different branches in place; it discards and re-renders. &lt;code&gt;import.meta.client&lt;/code&gt;/&lt;code&gt;.server&lt;/code&gt; are genuinely useful for deciding &lt;em&gt;what code runs&lt;/em&gt; (skip a browser-only import on the server, skip a Node-only one on the client) — they're the wrong tool for deciding what a hydrated template &lt;em&gt;renders&lt;/em&gt;, because that decision has to be identical in both places by definition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 4: when a mismatch is real, expected, and fine — &lt;code&gt;data-allow-mismatch&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Occasionally you'll have a value that will &lt;em&gt;always&lt;/em&gt; differ by design — a relative timestamp ("posted 3 minutes ago") that keeps ticking, for instance — and you've already accepted that as correct behavior rather than a bug. Vue 3.5 added an attribute for exactly this: &lt;code&gt;data-allow-mismatch&lt;/code&gt; silences the hydration warning for a specific element, scoped to the kind of mismatch you name (&lt;code&gt;text&lt;/code&gt;, &lt;code&gt;children&lt;/code&gt;, &lt;code&gt;class&lt;/code&gt;, &lt;code&gt;style&lt;/code&gt;, or &lt;code&gt;attribute&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;time&lt;/span&gt; &lt;span class="na"&gt;data-allow-mismatch=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ relativeTime }}&lt;span class="nt"&gt;&amp;lt;/time&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only suppresses the console warning — it does nothing to make the values agree. Reach for it after you've decided the mismatch is cosmetic and harmless, never as a first response to a warning you haven't diagnosed yet.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Invalid HTML nesting causes mismatches with no logic bug at all.&lt;/strong&gt; A &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; nested inside a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, or malformed &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; markup, gets silently corrected by the browser's HTML parser while it parses the server's HTML — the browser closes the &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; early, restructuring the tree Vue expected to hydrate onto. The fix is markup hygiene, not JavaScript: keep nesting valid per the HTML content model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser extensions mutate the DOM before your JS runs.&lt;/strong&gt; Grammarly, password managers, and dark-mode extensions routinely inject attributes into the page before hydration starts. These aren't your bug and can't be reliably prevented; &lt;code&gt;data-allow-mismatch="attribute"&lt;/code&gt; on the affected element is the pragmatic escape valve once you've confirmed the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server and client timezones differ.&lt;/strong&gt; A server running in UTC formatting a date directly in a template will disagree with a client in the visitor's local timezone. Same class of bug as &lt;code&gt;Date.now()&lt;/code&gt; — same fix: compute the display string in &lt;code&gt;onMounted&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;ref&lt;/code&gt; seeded from a browser API at module or setup scope.&lt;/strong&gt; &lt;code&gt;const isWide = ref(window.innerWidth &amp;gt; 768)&lt;/code&gt; throws on the server (there is no &lt;code&gt;window&lt;/code&gt;) or, if guarded, still needs a server-safe default and a client-side correction — the same &lt;code&gt;onMounted&lt;/code&gt; pattern applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared server state is a related but different bug.&lt;/strong&gt; If your mismatch is about the &lt;em&gt;wrong user's&lt;/em&gt; data appearing rather than a timing difference, that's the cross-request state leak, not a hydration mismatch — see the &lt;a href="https://dev.to/parsajiravand/nuxt-usestate-vs-ref-why-server-state-leaks-across-users-47n1"&gt;earlier episode on &lt;code&gt;useState&lt;/code&gt; vs. a plain &lt;code&gt;ref&lt;/code&gt;&lt;/a&gt; if that's the symptom you're chasing.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask one question of every render-affecting expression:&lt;/strong&gt; given the same props and payload, does this produce the exact same output on the server and the client? If the honest answer is "no," it doesn't belong directly in the template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default first, correct in &lt;code&gt;onMounted&lt;/code&gt;.&lt;/strong&gt; Any value that's allowed to differ per visitor gets a server-safe placeholder and a client-side update after mount — never a direct read of a browser API during setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; for whole widgets, not individual values.&lt;/strong&gt; If an entire component only makes sense in a browser (canvas-sized charts, &lt;code&gt;window&lt;/code&gt;-dependent libraries), don't fight it into an SSR-safe shape — skip SSR for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never branch a hydrated template's markup on &lt;code&gt;import.meta.client&lt;/code&gt;/&lt;code&gt;.server&lt;/code&gt;.&lt;/strong&gt; Use those flags to decide what code &lt;em&gt;runs&lt;/em&gt;, not what a hydrated component &lt;em&gt;renders&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lint your markup.&lt;/strong&gt; Invalid HTML nesting is an easy, boring source of mismatches that a markup or accessibility linter catches before it ever reaches a browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test against a production build, not just &lt;code&gt;nuxt dev&lt;/code&gt;.&lt;/strong&gt; Run &lt;code&gt;nuxt build &amp;amp;&amp;amp; nuxt preview&lt;/code&gt; before shipping something that touches SSR — dev's warnings are the same, but dev's timing can mask issues that show up under real hydration.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Does a hydration mismatch crash my app?
&lt;/h3&gt;

&lt;p&gt;No — Vue reconciles it either way. A text/attribute mismatch is patched in place; a structural one is discarded and re-rendered client-side. The app keeps working, but a structural mismatch means real extra work and a possible flash or loss of state in that subtree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the warning only appear in development?
&lt;/h3&gt;

&lt;p&gt;Vue's hydration mismatch console warning is a development-only diagnostic. In a production build, the same reconciliation happens, but silently — which is exactly why these bugs can ship unnoticed for a long time. Always sanity-check SSR-sensitive pages against a &lt;code&gt;nuxt preview&lt;/code&gt; build, not just dev.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; the same thing as checking &lt;code&gt;import.meta.client&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;import.meta.client&lt;/code&gt; is a compile-time flag that decides which lines of code are included in which bundle — it's a build-time tool. &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; is a runtime component that skips server rendering for its slot content and mounts it fresh in the browser. Using the flag to branch a hydrated template's markup causes the exact mismatch this article is about; &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; avoids it by never hydrating that content at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;useState&lt;/code&gt; prevent hydration mismatches?
&lt;/h3&gt;

&lt;p&gt;It prevents the specific class caused by state disagreeing between server and client, because its value is serialized into the payload and read identically on both sides. It doesn't protect a value your template computes independently of &lt;code&gt;useState&lt;/code&gt; — &lt;code&gt;Math.random()&lt;/code&gt; inside a &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; block is still a mismatch even if an unrelated &lt;code&gt;useState&lt;/code&gt; call exists elsewhere in the same component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a mismatch happen even when my code is completely correct?
&lt;/h3&gt;

&lt;p&gt;Yes. Third-party scripts and browser extensions can alter the DOM before your app hydrates, and that's outside your code's control. &lt;code&gt;data-allow-mismatch&lt;/code&gt; on the specific affected attribute is the accepted mitigation once you've confirmed that's the cause.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Math.random()&lt;/code&gt; / &lt;code&gt;Date.now()&lt;/code&gt; read during setup or render&lt;/td&gt;
&lt;td&gt;Text mismatch warning, value flickers on load&lt;/td&gt;
&lt;td&gt;Default to &lt;code&gt;null&lt;/code&gt;/placeholder, set the real value in &lt;code&gt;onMounted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;navigator&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt; in the template's data path&lt;/td&gt;
&lt;td&gt;Throws on server, or mismatches if guarded naively&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ref(defaultValue)&lt;/code&gt; + &lt;code&gt;onMounted&lt;/code&gt; to correct it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole widget only makes sense client-side (canvas size, browser-only lib)&lt;/td&gt;
&lt;td&gt;Mismatch or server crash&lt;/td&gt;
&lt;td&gt;Wrap it in &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; with a &lt;code&gt;#fallback&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;v-if="import.meta.client"&lt;/code&gt; branching a hydrated template&lt;/td&gt;
&lt;td&gt;Structural mismatch, guaranteed, every load&lt;/td&gt;
&lt;td&gt;Don't branch markup on the flag — use &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt;/&lt;code&gt;onMounted&lt;/code&gt; instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relative time / genuinely-expected drift you've accepted&lt;/td&gt;
&lt;td&gt;Warning you don't want to see&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;data-allow-mismatch="text"&lt;/code&gt; (Vue 3.5+) — after you've confirmed it's harmless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; inside &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, broken table markup&lt;/td&gt;
&lt;td&gt;Mismatch with no obvious cause in your JS&lt;/td&gt;
&lt;td&gt;Fix the HTML nesting; lint markup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grammarly / extensions injecting attributes&lt;/td&gt;
&lt;td&gt;Attribute mismatch you can't reproduce locally without the extension&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;data-allow-mismatch="attribute"&lt;/code&gt; on the affected element&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 vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMounted&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="c1"&gt;// Server-safe default — identical on both renders.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clientValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Runs only after hydration succeeds — safe to diverge here.&lt;/span&gt;
  &lt;span class="nx"&gt;clientValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeSomethingClientOnly&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;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;clientValue&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Loading…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- For whole subtrees that can never run on the server: --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ClientOnly&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;BrowserOnlyWidget&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;#fallback&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;Loading…&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ClientOnly&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bestpractic.org/blog/nuxt-weekly-hydration-mismatch/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;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A hydration mismatch happens because Nuxt renders your app twice — once on the server, once in the browser — and hydration assumes, without verifying up front, that both renders agree.&lt;/li&gt;
&lt;li&gt;The near-universal cause is a render-affecting value that isn't guaranteed identical on both sides: &lt;code&gt;Math.random()&lt;/code&gt;, &lt;code&gt;Date.now()&lt;/code&gt;, or any direct read of a browser-only API.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onMounted&lt;/code&gt; fixes values that are allowed to differ once hydration is already done; &lt;code&gt;&amp;lt;ClientOnly&amp;gt;&lt;/code&gt; fixes whole subtrees that can never run on the server; &lt;code&gt;data-allow-mismatch&lt;/code&gt; only silences a warning you've already confirmed is harmless.&lt;/li&gt;
&lt;li&gt;Never branch a hydrated template's markup on &lt;code&gt;import.meta.client&lt;/code&gt;/&lt;code&gt;.server&lt;/code&gt; — that's the one "fix" that reliably causes the exact bug it's trying to solve.&lt;/li&gt;
&lt;/ul&gt;

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

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

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

&lt;h2&gt;
  
  
  One more render to get right
&lt;/h2&gt;

&lt;p&gt;That tip-of-the-day widget from the top of this article has an honest fix now — a &lt;code&gt;ref&lt;/code&gt; that starts &lt;code&gt;null&lt;/code&gt; and fills in after mount, instead of a &lt;code&gt;Math.random()&lt;/code&gt; call sitting directly in the render path. The bug was never really about randomness; it was about &lt;em&gt;where&lt;/em&gt; the randomness ran, and Nuxt was always going to run it twice.&lt;/p&gt;

&lt;p&gt;What's the strangest hydration mismatch you've had to track down — a third-party script, a timezone, something stranger? Drop it in the comments; there's a decent chance someone else's next &lt;code&gt;[Vue warn]&lt;/code&gt; is exactly the one you already solved.&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>nuxt</category>
      <category>ssr</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
