[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"search-suggestions":44,"article-service-worker-stale-cache-deploy":92,"related-service-worker-stale-cache-deploy":281,"code:js:true:13e6kn1":414,"code:js:true:yaqd6o":415,"code:js:true:vjojcw":416,"comments-01a114fb-f147-727a-a137-53c8ce35f6bf":417},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,[45,49,53,57,61,65,69,73,77,81,85,89],{"slug":46,"name":47,"articles":48},"webdev","Webdev",135,{"slug":50,"name":51,"articles":52},"javascript","Javascript",111,{"slug":54,"name":55,"articles":56},"frontend","Frontend",82,{"slug":58,"name":59,"articles":60},"tutorial","Tutorial",52,{"slug":62,"name":63,"articles":64},"css","Css",42,{"slug":66,"name":67,"articles":68},"performance","Performance",19,{"slug":70,"name":71,"articles":72},"typescript","Typescript",18,{"slug":74,"name":75,"articles":76},"react","React",16,{"slug":78,"name":79,"articles":80},"browser","Browser",13,{"slug":82,"name":83,"articles":84},"node","Node",12,{"slug":86,"name":87,"articles":88},"accessibility","Accessibility",9,{"slug":90,"name":91,"articles":88},"programming","Programming",{"id":93,"slug":94,"title":95,"subtitle":96,"excerpt":97,"coverUrl":98,"locale":13,"readingMinutes":99,"publishedAt":100,"viewCount":101,"likeCount":19,"commentCount":19,"author":102,"vertical":107,"topic":108,"tags":110,"_count":115,"playground":117,"body":119,"bodyMd":264,"seo":265,"translationGroupId":268,"series":96,"podcastUrl":96,"verticalId":5,"thread":269,"assessments":271,"translations":277,"quiz":279},"01a114fb-f147-727a-a137-53c8ce35f6bf","service-worker-stale-cache-deploy","I Shipped a Fix. My Service Worker Kept Serving the Bug.",null,"A critical bug fix went out, and support tickets kept coming in from people who swore they'd refreshed. The code was fine. The service worker's cache-first strategy was doing its job perfectly — which was the whole problem.","\u002Fmedia\u002Fcovers\u002Fservice-worker-stale-cache-deploy.png",6,"2026-10-12T06:10:22.830Z",31,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":50,"name":109},"JavaScript",[111,112,113,114],{"slug":50,"name":51,"color":96},{"slug":46,"name":47,"color":96},{"slug":78,"name":79,"color":96},{"slug":66,"name":67,"color":96},{"assessments":116},1,{"slug":94,"title":118},"Service worker deploy — interactive playground",{"blocks":120,"version":116},[121,125,128,131,136,139,142,146,149,155,158,161,164,167,170,173,176,179,182,186,189,192,199,203,206,210,213,216,219,222,225,228,231,234,237,240,246,249,252,255,258],{"id":122,"html":123,"type":124},"b1","\u003Cp>Three days after a critical fix went live, the support queue still had tickets coming in — all describing the exact bug you&#39;d already closed. Every reply said the same thing: &quot;I refreshed, it&#39;s still broken.&quot; A few said they&#39;d refreshed twice.\u003C\u002Fp>","paragraph",{"id":126,"html":127,"type":124},"b2","\u003Cp>You pull up the deployed bundle. The fix is there, served from the server, timestamped correctly. You open the site in an incognito window and it works. You open it in your own regular browser, the one you&#39;ve had open all week, and there&#39;s the bug — your bug, the one you already fixed, running code from four days ago.\u003C\u002Fp>",{"id":129,"html":130,"type":124},"b3","\u003Cp>Nothing about your deploy was wrong. The thing lying to your users was sitting in their browser, and it was doing exactly what you told it to do.\u003C\u002Fp>",{"id":132,"html":133,"text":134,"type":135,"level":31},"b4","The obvious fix that doesn&#39;t fix it","The obvious fix that doesn't fix it","heading",{"id":137,"html":138,"type":124},"b5","\u003Cp>The instinct is &quot;tell them to hard-refresh.&quot; Ctrl+Shift+R, clear cache, the works. It helps some of your users and does nothing for the rest, because a service worker isn&#39;t the browser&#39;s HTTP cache — it&#39;s a script that intercepts every \u003Ccode>fetch\u003C\u002Fcode> on your origin and decides, in your code, what to answer with. A normal reload doesn&#39;t bypass it. A hard refresh sometimes does, depending on browser and whether the service worker itself is also cached. Telling users to fight their own browser is not a fix; it&#39;s a workaround you&#39;re asking thousands of people to perform by hand, repeatedly, every time you ship.\u003C\u002Fp>",{"id":140,"html":141,"type":124},"b6","\u003Cp>The real question isn&#39;t &quot;how do we get users to clear their cache.&quot; It&#39;s &quot;why didn&#39;t the new version take over automatically&quot; — and that&#39;s a lifecycle question, not a caching question.\u003C\u002Fp>",{"id":143,"html":144,"text":145,"type":135,"level":31},"b7","What&#39;s actually happening","What's actually happening",{"id":147,"html":148,"type":124},"b8","\u003Cp>A cache-first strategy looks like this, and it&#39;s genuinely the right choice for a lot of assets:\u003C\u002Fp>",{"id":150,"code":151,"type":152,"language":153,"highlight":154},"b9","self.addEventListener(\"fetch\", (event) => {\n  event.respondWith(\n    caches.match(event.request).then((cached) => cached || fetch(event.request))\n  );\n});","code","js",[],{"id":156,"html":157,"type":124},"b10","\u003Cp>Check the cache. If it&#39;s there, serve it — no network round trip, instant response, works offline. That&#39;s the entire appeal of a service worker for a performance-conscious team, and it&#39;s why this pattern shows up in so many tutorials. The trap is what &quot;cache-first&quot; means for the one file that tells the browser everything else changed: if \u003Ccode>index.html\u003C\u002Fcode> or your main JS bundle is cache-first too, the browser never even asks the network whether a new version exists. It&#39;s handed the old answer before the question can be asked.\u003C\u002Fp>",{"id":159,"html":160,"type":124},"b11","\u003Cp>So you deploy a new service worker with a fix for this — and it still doesn&#39;t help, for a second, separate reason: \u003Cstrong>a new service worker doesn&#39;t take over the instant it&#39;s installed.\u003C\u002Fstrong> The lifecycle is install → waiting → activate, and by design, a waiting service worker sits idle as long as \u003Cem>any\u003C\u002Fem> tab is still controlled by the old one. Your users don&#39;t close all their tabs. Some of them haven&#39;t closed that tab since Monday. So the new worker waits, and waits, and the old one keeps answering every request with cache-first confidence.\u003C\u002Fp>",{"id":162,"html":163,"type":124},"b12","\u003C!-- playground:start -->",{"id":165,"html":166,"text":166,"type":135,"level":31},"b13","🎮 Try it yourself",{"id":168,"html":169,"type":124},"b14","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fservice-worker-stale-cache-deploy\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":171,"html":172,"type":124},"b15","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":174,"html":175,"type":124},"b16","\u003C!-- playground:end -->",{"id":177,"html":178,"text":178,"type":135,"level":31},"b17","The two lines that actually fix it",{"id":180,"html":181,"type":124},"b18","\u003Cp>Getting a new version to take over immediately is two separate opt-ins, and skipping either one leaves the bug half-fixed:\u003C\u002Fp>",{"id":183,"code":184,"type":152,"language":153,"highlight":185},"b19","self.addEventListener(\"install\", (event) => {\n  event.waitUntil(self.skipWaiting()); \u002F\u002F don't wait for old tabs to close\n});\n\nself.addEventListener(\"activate\", (event) => {\n  event.waitUntil(clients.claim()); \u002F\u002F take control of open tabs right now\n});",[],{"id":187,"html":188,"type":124},"b20","\u003Cp>\u003Ccode>skipWaiting()\u003C\u002Fcode> moves the new worker out of &quot;waiting&quot; the moment it finishes installing, instead of parking until every old tab closes. \u003Ccode>clients.claim()\u003C\u002Fcode> is the other half: without it, a freshly-activated worker still only controls tabs that load \u003Cem>after\u003C\u002Fem> it activates — any tab that was already open keeps talking to whichever worker controlled it when it loaded. Call both, and a currently-open tab&#39;s very next fetch is answered by the new worker.\u003C\u002Fp>",{"id":190,"html":191,"type":124},"b21","\u003Cp>That solves &quot;the new version never takes over.&quot; It doesn&#39;t solve the first problem — a cache-first \u003Ccode>index.html\u003C\u002Fcode> or main bundle will still serve whatever that new worker \u003Cem>finds in its cache\u003C\u002Fem>, and if the install step cached the old files before the fetch handler takes over, you&#39;ve just made the stale response arrive faster. The fix there is matching the strategy to the file:\u003C\u002Fp>",{"id":193,"type":194,"items":195,"ordered":18},"b22","list",[196,197,198],"\u003Cstrong>HTML and anything that names your entry bundle\u003C\u002Fstrong> — network-first, or no caching at all. These are cheap to fetch and they&#39;re the one place &quot;slightly stale&quot; becomes &quot;actively wrong.&quot;","\u003Cstrong>Fingerprinted, hashed assets\u003C\u002Fstrong> (\u003Ccode>app.a3f91c.js\u003C\u002Fcode>) — cache-first is correct and free. The filename changes when the content does, so there&#39;s no staleness to worry about; the old file and the new file are simply different URLs.","\u003Cstrong>Everything in between\u003C\u002Fstrong> (an API response, a non-hashed icon) — stale-while-revalidate: answer instantly from cache, and kick off a network fetch in the background that updates the cache for \u003Cem>next\u003C\u002Fem> time.",{"id":200,"code":201,"type":152,"language":153,"highlight":202},"b23","const CACHE_NAME = \"app-cache-v7\"; \u002F\u002F bump this every deploy\n\nself.addEventListener(\"activate\", (event) => {\n  event.waitUntil(\n    caches.keys().then((keys) =>\n      Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))\n    )\n  );\n});",[],{"id":204,"html":205,"type":124},"b24","\u003Cp>That version bump is the detail cache-first tutorials leave out most often. Called the way the fetch handler above calls it — with no \u003Ccode>cacheName\u003C\u002Fcode> option — \u003Ccode>caches.match()\u003C\u002Fcode> doesn&#39;t just check the cache you&#39;re currently writing to; it searches \u003Cem>every\u003C\u002Fem> cache under your origin, oldest first, and returns the first match it finds. Skip the activate-handler cleanup and a stale, un-deleted \u003Ccode>app-cache-v6\u003C\u002Fcode> can still win that search over the \u003Ccode>app-cache-v7\u003C\u002Fcode> entry sitting right next to it, even though the newer cache already has the file you just deployed. Deleting the old cache on activate isn&#39;t just tidiness — it&#39;s what guarantees \u003Ccode>caches.match()\u003C\u002Fcode> has nothing stale left to find.\u003C\u002Fp>",{"id":207,"html":208,"text":209,"type":135,"level":31},"b25","Why this isn&#39;t a service worker bug","Why this isn't a service worker bug",{"id":211,"html":212,"type":124},"b26","\u003Cp>None of this is the API misbehaving. A service worker that keeps serving cached content after a deploy is doing precisely what cache-first plus &quot;wait for every tab to close&quot; plus &quot;never evict the old cache&quot; adds up to. The lifecycle exists so a new version can&#39;t yank state out from under a tab mid-interaction — that&#39;s the feature, not an oversight. The bug is in treating &quot;cache it&quot; as a complete caching strategy, when the strategy actually has three separate knobs: what gets cached, when the new worker takes over, and when the old cache gets deleted. Ship a fix to only one of those and the other two will happily keep serving yesterday&#39;s bug to someone who is absolutely certain they refreshed.\u003C\u002Fp>",{"id":214,"html":215,"type":124},"b27","\u003C!-- quiz:start -->",{"id":217,"html":218,"text":218,"type":135,"level":31},"b28","🧠 Test yourself",{"id":220,"html":221,"type":124},"b29","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fservice-worker-stale-cache-deploy\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":223,"html":224,"type":124},"b30","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":226,"html":227,"type":124},"b31","\u003C!-- quiz:end -->",{"id":229,"html":230,"type":124},"b32","\u003Cp>Next time a user says &quot;I refreshed and it&#39;s still broken,&quot; the first question isn&#39;t &quot;did you refresh hard enough.&quot; It&#39;s which of your three knobs you forgot to turn.\u003C\u002Fp>",{"id":232,"html":233,"type":124},"b33","\u003Cp>What&#39;s the longest a stale deploy has haunted one of your users before someone thought to check the service worker?\u003C\u002Fp>",{"id":235,"html":236,"type":124},"b34","\u003C!-- related:start -->",{"id":238,"html":239,"text":239,"type":135,"level":31},"b35","📚 Read next",{"id":241,"type":194,"items":242,"ordered":18},"b36",[243,244,245],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers\">Your Timer Doesn&#39;t Know You Tabbed Away\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-cache-components-explained\">Next.js Cache Components Explained (with Cheat Sheet)\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js\">Stop Debouncing Resize. The Browser Already Watches It.\u003C\u002Fa>",{"id":247,"html":248,"type":124},"b37","\u003C!-- related:end -->",{"id":250,"type":251},"b38","divider",{"id":253,"html":254,"type":124},"b39","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":256,"html":257,"type":124},"b40","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":259,"type":194,"items":260,"ordered":18},"b41",[261,262,263],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","Three days after a critical fix went live, the support queue still had tickets coming in — all describing the exact bug you'd already closed. Every reply said the same thing: \"I refreshed, it's still broken.\" A few said they'd refreshed twice.\n\nYou pull up the deployed bundle. The fix is there, served from the server, timestamped correctly. You open the site in an incognito window and it works. You open it in your own regular browser, the one you've had open all week, and there's the bug — your bug, the one you already fixed, running code from four days ago.\n\nNothing about your deploy was wrong. The thing lying to your users was sitting in their browser, and it was doing exactly what you told it to do.\n\n## The obvious fix that doesn't fix it\n\nThe instinct is \"tell them to hard-refresh.\" Ctrl+Shift+R, clear cache, the works. It helps some of your users and does nothing for the rest, because a service worker isn't the browser's HTTP cache — it's a script that intercepts every `fetch` on your origin and decides, in your code, what to answer with. A normal reload doesn't bypass it. A hard refresh sometimes does, depending on browser and whether the service worker itself is also cached. Telling users to fight their own browser is not a fix; it's a workaround you're asking thousands of people to perform by hand, repeatedly, every time you ship.\n\nThe real question isn't \"how do we get users to clear their cache.\" It's \"why didn't the new version take over automatically\" — and that's a lifecycle question, not a caching question.\n\n## What's actually happening\n\nA cache-first strategy looks like this, and it's genuinely the right choice for a lot of assets:\n\n```js\nself.addEventListener(\"fetch\", (event) => {\n  event.respondWith(\n    caches.match(event.request).then((cached) => cached || fetch(event.request))\n  );\n});\n```\n\nCheck the cache. If it's there, serve it — no network round trip, instant response, works offline. That's the entire appeal of a service worker for a performance-conscious team, and it's why this pattern shows up in so many tutorials. The trap is what \"cache-first\" means for the one file that tells the browser everything else changed: if `index.html` or your main JS bundle is cache-first too, the browser never even asks the network whether a new version exists. It's handed the old answer before the question can be asked.\n\nSo you deploy a new service worker with a fix for this — and it still doesn't help, for a second, separate reason: **a new service worker doesn't take over the instant it's installed.** The lifecycle is install → waiting → activate, and by design, a waiting service worker sits idle as long as *any* tab is still controlled by the old one. Your users don't close all their tabs. Some of them haven't closed that tab since Monday. So the new worker waits, and waits, and the old one keeps answering every request with cache-first confidence.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fservice-worker-stale-cache-deploy\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The two lines that actually fix it\n\nGetting a new version to take over immediately is two separate opt-ins, and skipping either one leaves the bug half-fixed:\n\n```js\nself.addEventListener(\"install\", (event) => {\n  event.waitUntil(self.skipWaiting()); \u002F\u002F don't wait for old tabs to close\n});\n\nself.addEventListener(\"activate\", (event) => {\n  event.waitUntil(clients.claim()); \u002F\u002F take control of open tabs right now\n});\n```\n\n`skipWaiting()` moves the new worker out of \"waiting\" the moment it finishes installing, instead of parking until every old tab closes. `clients.claim()` is the other half: without it, a freshly-activated worker still only controls tabs that load *after* it activates — any tab that was already open keeps talking to whichever worker controlled it when it loaded. Call both, and a currently-open tab's very next fetch is answered by the new worker.\n\nThat solves \"the new version never takes over.\" It doesn't solve the first problem — a cache-first `index.html` or main bundle will still serve whatever that new worker *finds in its cache*, and if the install step cached the old files before the fetch handler takes over, you've just made the stale response arrive faster. The fix there is matching the strategy to the file:\n\n- **HTML and anything that names your entry bundle** — network-first, or no caching at all. These are cheap to fetch and they're the one place \"slightly stale\" becomes \"actively wrong.\"\n- **Fingerprinted, hashed assets** (`app.a3f91c.js`) — cache-first is correct and free. The filename changes when the content does, so there's no staleness to worry about; the old file and the new file are simply different URLs.\n- **Everything in between** (an API response, a non-hashed icon) — stale-while-revalidate: answer instantly from cache, and kick off a network fetch in the background that updates the cache for *next* time.\n\n```js\nconst CACHE_NAME = \"app-cache-v7\"; \u002F\u002F bump this every deploy\n\nself.addEventListener(\"activate\", (event) => {\n  event.waitUntil(\n    caches.keys().then((keys) =>\n      Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))\n    )\n  );\n});\n```\n\nThat version bump is the detail cache-first tutorials leave out most often. Called the way the fetch handler above calls it — with no `cacheName` option — `caches.match()` doesn't just check the cache you're currently writing to; it searches *every* cache under your origin, oldest first, and returns the first match it finds. Skip the activate-handler cleanup and a stale, un-deleted `app-cache-v6` can still win that search over the `app-cache-v7` entry sitting right next to it, even though the newer cache already has the file you just deployed. Deleting the old cache on activate isn't just tidiness — it's what guarantees `caches.match()` has nothing stale left to find.\n\n## Why this isn't a service worker bug\n\nNone of this is the API misbehaving. A service worker that keeps serving cached content after a deploy is doing precisely what cache-first plus \"wait for every tab to close\" plus \"never evict the old cache\" adds up to. The lifecycle exists so a new version can't yank state out from under a tab mid-interaction — that's the feature, not an oversight. The bug is in treating \"cache it\" as a complete caching strategy, when the strategy actually has three separate knobs: what gets cached, when the new worker takes over, and when the old cache gets deleted. Ship a fix to only one of those and the other two will happily keep serving yesterday's bug to someone who is absolutely certain they refreshed.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fservice-worker-stale-cache-deploy\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nNext time a user says \"I refreshed and it's still broken,\" the first question isn't \"did you refresh hard enough.\" It's which of your three knobs you forgot to turn.\n\nWhat's the longest a stale deploy has haunted one of your users before someone thought to check the service worker?\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Your Timer Doesn't Know You Tabbed Away](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers)\n- [Next.js Cache Components Explained (with Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-cache-components-explained)\n- [Stop Debouncing Resize. The Browser Already Watches It.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fmatchmedia-watch-media-queries-from-js)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":95,"canonical":266,"description":267},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fservice-worker-stale-cache-deploy","A critical bug fix went out, and support tickets kept coming in from people who swore they'd refreshed. The code was fine. The service worker's cache-first strategy was doing its j","01a114fb-f148-717d-a343-7119e8895013",{"id":270,"locked":18},"01a114fb-f16f-7399-9ed7-a467825bc5f8",[272],{"id":273,"slug":94,"title":274,"_count":275},"01a114fb-f19f-74a9-b69e-94217adba56a","Service workers, cache strategies, and stale deploys",{"questions":276},8,[278],{"locale":13,"slug":94},{"id":273,"slug":94,"title":274,"_count":280,"questionCount":276},{"questions":276},{"items":282,"meta":412},[283,304,316,337,355,373,394],{"id":284,"slug":285,"title":286,"subtitle":96,"excerpt":287,"coverUrl":288,"locale":13,"readingMinutes":289,"publishedAt":290,"viewCount":291,"likeCount":19,"commentCount":19,"author":292,"vertical":293,"topic":294,"tags":297,"_count":302,"playground":303,"hasQuiz":17,"hasPlayground":17},"01a0ec73-2e41-711f-9f8a-b6c9bae1e054","vue-weekly-watch-vs-watcheffect","Vue watch vs watchEffect: The Complete Guide","Vue's watch() and watchEffect() look interchangeable but track dependencies, run timing, and cleanup differently. The exact rules, verified against Vue 3.5.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-watch-vs-watcheffect.png",15,"2026-10-12T06:10:54.504Z",34,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":295,"name":296},"vue","Vue",[298,299,300,301],{"slug":295,"name":296,"color":96},{"slug":50,"name":51,"color":96},{"slug":58,"name":59,"color":96},{"slug":46,"name":47,"color":96},{"assessments":116},{"slug":285},{"id":93,"slug":94,"title":95,"subtitle":96,"excerpt":97,"coverUrl":98,"locale":13,"readingMinutes":99,"publishedAt":100,"viewCount":305,"likeCount":19,"commentCount":19,"author":306,"vertical":307,"topic":308,"tags":309,"_count":314,"playground":315,"hasQuiz":17,"hasPlayground":17},32,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":50,"name":109},[310,311,312,313],{"slug":50,"name":51,"color":96},{"slug":46,"name":47,"color":96},{"slug":78,"name":79,"color":96},{"slug":66,"name":67,"color":96},{"assessments":116},{"slug":94},{"id":317,"slug":318,"title":319,"subtitle":96,"excerpt":320,"coverUrl":321,"locale":13,"readingMinutes":322,"publishedAt":323,"viewCount":324,"likeCount":19,"commentCount":19,"author":325,"vertical":326,"topic":327,"tags":330,"_count":335,"playground":336,"hasQuiz":17,"hasPlayground":17},"01a11d02-8b28-774b-9f36-fb66bece8e50","nuxt-weekly-4-migration-breaking-changes","Nuxt 4 Migration: The Breaking Changes That Actually Bite","Nuxt 3 reached end-of-life on July 31, 2026. Here's exactly what breaks moving to Nuxt 4 — directories, data fetching, TypeScript — plus a cheat sheet.","\u002Fmedia\u002Fcovers\u002Fnuxt-weekly-4-migration-breaking-changes.png",14,"2026-10-11T12:26:26.492Z",87,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":328,"name":329},"nuxt","Nuxt",[331,332,333,334],{"slug":328,"name":329,"color":96},{"slug":50,"name":51,"color":96},{"slug":58,"name":59,"color":96},{"slug":46,"name":47,"color":96},{"assessments":116},{"slug":318},{"id":338,"slug":339,"title":340,"subtitle":96,"excerpt":341,"coverUrl":342,"locale":13,"readingMinutes":99,"publishedAt":343,"viewCount":344,"likeCount":19,"commentCount":19,"author":345,"vertical":346,"topic":347,"tags":348,"_count":353,"playground":354,"hasQuiz":17,"hasPlayground":17},"01a10fd5-be9a-719b-8acf-e0e930c602f8","long-animation-frames-api","Your Lighthouse Score Is 100. Long Animation Frames Explain the Jank.","Lighthouse says 100 and CI is green, but a user still says the filter button feels sluggish. The Long Animation Frames API names the exact script and phase that blocked the frame — here's how to read it instead of squinting at a flame chart.","\u002Fmedia\u002Fcovers\u002Flong-animation-frames-api.png","2026-10-11T12:25:55.553Z",95,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":50,"name":109},[349,350,351,352],{"slug":50,"name":51,"color":96},{"slug":66,"name":67,"color":96},{"slug":46,"name":47,"color":96},{"slug":90,"name":91,"color":96},{"assessments":116},{"slug":339},{"id":356,"slug":357,"title":358,"subtitle":96,"excerpt":359,"coverUrl":360,"locale":13,"readingMinutes":80,"publishedAt":361,"viewCount":362,"likeCount":19,"commentCount":19,"author":363,"vertical":364,"topic":365,"tags":366,"_count":371,"playground":372,"hasQuiz":17,"hasPlayground":17},"01a117e0-acb2-740f-9391-db919729cf21","react-weekly-fragment-refs","React 19.3 Fragment Refs: Skip the Wrapper Div for a Ref","React 19.3 Fragment Refs let you ref a sibling group with no wrapper div. Learn the FragmentInstance API, its limits, and get a copy-paste cheat sheet.","\u002Fmedia\u002Fcovers\u002Freact-weekly-fragment-refs.png","2026-10-10T12:12:38.989Z",138,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":74,"name":75},[367,368,369,370],{"slug":74,"name":75,"color":96},{"slug":50,"name":51,"color":96},{"slug":58,"name":59,"color":96},{"slug":46,"name":47,"color":96},{"assessments":116},{"slug":357},{"id":374,"slug":375,"title":376,"subtitle":96,"excerpt":377,"coverUrl":378,"locale":13,"readingMinutes":379,"publishedAt":380,"viewCount":381,"likeCount":19,"commentCount":19,"author":382,"vertical":383,"topic":384,"tags":385,"_count":392,"playground":393,"hasQuiz":17,"hasPlayground":17},"01a1017f-c2ac-70af-a013-25321448f53a","unhandledrejection-swallowed-promise-errors","Stop Losing Promise Errors. `unhandledrejection` Catches Them.","A rejected promise with no .catch doesn't crash and doesn't show up in your error tracker — it just disappears. The unhandledrejection event is the global safety net most apps never wire up.","\u002Fmedia\u002Fcovers\u002Funhandledrejection-swallowed-promise-errors.png",5,"2026-10-08T06:10:23.452Z",242,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":50,"name":109},[386,387,388,389],{"slug":50,"name":51,"color":96},{"slug":46,"name":47,"color":96},{"slug":90,"name":91,"color":96},{"slug":390,"name":391,"color":96},"debugging","Debugging",{"assessments":116},{"slug":375},{"id":395,"slug":396,"title":397,"subtitle":96,"excerpt":398,"coverUrl":399,"locale":13,"readingMinutes":99,"publishedAt":400,"viewCount":401,"likeCount":19,"commentCount":19,"author":402,"vertical":403,"topic":404,"tags":405,"_count":410,"playground":411,"hasQuiz":17,"hasPlayground":17},"01a0fb3c-4e87-7604-9b6e-d9f518d232be","fetch-retry-exponential-backoff","Stop Retrying Immediately. Exponential Backoff Fixes It.","Retrying a failed fetch() right away feels responsible — until the server that failed was already overloaded, and five instant retries per client turn a blip into an outage. Exponential backoff with jitter is the fix.","\u002Fmedia\u002Fcovers\u002Ffetch-retry-exponential-backoff.png","2026-10-07T06:10:20.335Z",428,{"id":103,"name":104,"username":105,"avatarUrl":96,"headline":106},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":50,"name":109},[406,407,408,409],{"slug":50,"name":51,"color":96},{"slug":46,"name":47,"color":96},{"slug":90,"name":91,"color":96},{"slug":82,"name":83,"color":96},{"assessments":116},{"slug":396},{"page":116,"perPage":413,"total":52,"totalPages":76},7,"\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">self\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"fetch\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>event\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  event\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">respondWith\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\n    caches\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">match\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>event\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>request\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">then\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>cached\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> cached \u003Cspan class=\"shj-oper\">||\u003C\u002Fspan> \u003Cspan class=\"shj-func\">fetch\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>event\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>request\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">self\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"install\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>event\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  event\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">waitUntil\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>self\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">skipWaiting\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>; \u003Cspan class=\"shj-cmnt\">\u002F\u002F don't wait for old tabs to close\n\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n\nself\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"activate\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>event\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  event\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">waitUntil\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>clients\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">claim\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>; \u003Cspan class=\"shj-cmnt\">\u002F\u002F take control of open tabs right now\n\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003Cdiv>9\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> \u003Cspan class=\"shj-num\">CACHE_NAME\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-str\">\"app-cache-v7\"\u003C\u002Fspan>; \u003Cspan class=\"shj-cmnt\">\u002F\u002F bump this every deploy\n\u003C\u002Fspan>\nself\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"activate\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>event\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  event\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">waitUntil\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\n    caches\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">keys\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">then\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>keys\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan>\n      \u003Cspan class=\"shj-class\">Promise\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">all\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>keys\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">filter\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>k\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> k \u003Cspan class=\"shj-oper\">!==\u003C\u002Fspan> \u003Cspan class=\"shj-num\">CACHE_NAME\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">map\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>k\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> caches\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">delete\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>k\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\n    \u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>",{"locked":18,"total":19,"comments":418},[]]