[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-page-visibility-api-tab-aware-timers":44,"search-suggestions":60,"quiz-article-page-visibility-api-tab-aware-timers":106},[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,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a057f2-68ea-7608-841a-5b1f3096db0e","page-visibility-api-tab-aware-timers","PRACTICE_QUIZ","Page Visibility API","Check what actually stuck about visibilitychange, document.hidden, and why it beats window blur\u002Ffocus for detecting whether anyone can see the page.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,88,92,96,100,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",81,{"slug":66,"name":67,"articles":68},"javascript","Javascript",68,{"slug":70,"name":71,"articles":72},"frontend","Frontend",67,{"slug":74,"name":75,"articles":76},"css","Css",29,{"slug":78,"name":79,"articles":80},"tutorial","Tutorial",17,{"slug":82,"name":83,"articles":84},"typescript","Typescript",11,{"slug":86,"name":87,"articles":84},"performance","Performance",{"slug":89,"name":90,"articles":91},"react","React",9,{"slug":93,"name":94,"articles":95},"grammar","Grammar",6,{"slug":97,"name":98,"articles":99},"html","Html",5,{"slug":101,"name":102,"articles":99},"node","Node",{"slug":104,"name":105,"articles":99},"browser","Browser",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":111,"publishedAt":112,"viewCount":113,"likeCount":19,"commentCount":19,"author":114,"vertical":119,"topic":120,"tags":122,"_count":127,"playground":129,"body":131,"bodyMd":261,"seo":262,"translationGroupId":265,"series":52,"podcastUrl":52,"verticalId":5,"thread":266,"assessments":268,"translations":271,"quiz":273},"01a057f2-68b2-7521-a373-0ea70eae6f55","Your Timer Doesn't Know You Tabbed Away","window blur feels like the obvious way to detect an idle tab, and it's wrong in ways that only show up in production. The Page Visibility API answers the actual question — can anyone see this? — so polling, timers, and video stop wasting work the moment nobody's looking.","\u002Fmedia\u002Fcovers\u002Fpage-visibility-api-tab-aware-timers.png",4,"2026-09-05T10:17:19.010Z",30,{"id":115,"name":116,"username":117,"avatarUrl":52,"headline":118},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":121},"JavaScript",[123,124,125,126],{"slug":62,"name":63,"color":52},{"slug":66,"name":67,"color":52},{"slug":86,"name":87,"color":52},{"slug":104,"name":105,"color":52},{"assessments":128},1,{"slug":46,"title":130},"Page Visibility API — interactive playground",{"blocks":132,"version":128},[133,137,140,144,147,153,156,159,162,165,168,175,178,181,184,187,190,193,196,199,203,206,209,212,216,219,222,225,228,231,234,237,240,243,246,249,252,255],{"id":134,"html":135,"type":136},"b1","\u003Cp>Picture a dashboard tile polling an endpoint every two seconds to keep a number fresh. You switch to Slack to answer a message. Four minutes later you&#39;re still there, and the tile is still polling — a request every two seconds, into a tab nobody has looked at since you left it.\u003C\u002Fp>","paragraph",{"id":138,"html":139,"type":136},"b2","\u003Cp>Multiply that by however many tabs your users leave open, and it&#39;s not a rounding error. It&#39;s server load and battery drain for work that produces zero value, because the one piece of information that would have stopped it — \u003Cem>is anyone actually looking at this\u003C\u002Fem> — was never checked.\u003C\u002Fp>",{"id":141,"html":142,"text":142,"type":143,"level":31},"b3","The fix that feels obvious","heading",{"id":145,"html":146,"type":136},"b4","\u003Cp>The instinct is to pause on \u003Ccode>blur\u003C\u002Fcode> and resume on \u003Ccode>focus\u003C\u002Fcode>. It reads clean:\u003C\u002Fp>",{"id":148,"code":149,"type":150,"language":151,"highlight":152},"b5","window.addEventListener(\"blur\", () => clearInterval(pollId));\nwindow.addEventListener(\"focus\", () => { pollId = startPolling(); });","code","js",[],{"id":154,"html":155,"type":136},"b6","\u003Cp>Ship that, and eventually someone files a strange bug: a video that&#39;s supposed to keep playing in the background pauses when they click into DevTools. Or a live ticker that stops updating the instant they open a browser extension&#39;s popup. Or — the one that&#39;s easy to miss because it only happens on multi-monitor setups — a dashboard that &quot;goes idle&quot; while it&#39;s sitting fully visible on a second screen, because the user is typing in a different app on their main monitor.\u003C\u002Fp>",{"id":157,"html":158,"type":136},"b7","\u003Cp>None of those tabs are hidden. The content is right there on the glass. \u003Ccode>blur\u003C\u002Fcode> fired anyway, because \u003Ccode>blur\u003C\u002Fcode> and \u003Ccode>focus\u003C\u002Fcode> answer a narrower question than the one you actually asked.\u003C\u002Fp>",{"id":160,"html":161,"text":161,"type":143,"level":31},"b8","The question you meant to ask",{"id":163,"html":164,"type":136},"b9","\u003Cp>\u003Ccode>blur\u003C\u002Fcode>\u002F\u003Ccode>focus\u003C\u002Fcode> tell you whether a window has \u003Cem>keyboard focus\u003C\u002Fem>. That&#39;s genuinely useful for things like &quot;should this input show its focus ring&quot; — it is not the same as &quot;can a human currently see this content.&quot; A window can lose focus while staying fully rendered and visible; that&#39;s exactly the second-monitor case above, and it&#39;s why MDN&#39;s own guidance singles this out when explaining why the Page Visibility API exists at all, rather than just reusing focus events.\u003C\u002Fp>",{"id":166,"html":167,"type":136},"b10","\u003Cp>The API that answers the real question is small:\u003C\u002Fp>",{"id":169,"type":170,"items":171,"ordered":18},"b11","list",[172,173,174],"\u003Ccode>document.visibilityState\u003C\u002Fcode> — \u003Ccode>&quot;visible&quot;\u003C\u002Fcode> or \u003Ccode>&quot;hidden&quot;\u003C\u002Fcode>.","\u003Ccode>document.hidden\u003C\u002Fcode> — the older boolean shorthand for the same thing.","the \u003Ccode>visibilitychange\u003C\u002Fcode> event, fired on \u003Ccode>document\u003C\u002Fcode> whenever that state flips.",{"id":176,"html":177,"type":136},"b12","\u003Cp>It goes &quot;hidden&quot; when the tab is switched away from, the window is minimized, or (on most platforms) the screen locks — the set of conditions where the content is, as far as the browser can tell, definitely not on anyone&#39;s retina. It stays \u003Ccode>&quot;visible&quot;\u003C\u002Fcode> in the second-monitor case above, because it correctly is. This has been standard and unprefixed in every evergreen browser for well over a decade — there&#39;s no feature-detection dance required.\u003C\u002Fp>",{"id":179,"html":180,"type":136},"b13","\u003C!-- playground:start -->",{"id":182,"html":183,"text":183,"type":143,"level":31},"b14","🎮 Try it yourself",{"id":185,"html":186,"type":136},"b15","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":188,"html":189,"type":136},"b16","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":191,"html":192,"type":136},"b17","\u003C!-- playground:end -->",{"id":194,"html":195,"text":195,"type":143,"level":31},"b18","Wiring it up properly",{"id":197,"html":198,"type":136},"b19","\u003Cp>Swap the listener, not just the vocabulary:\u003C\u002Fp>",{"id":200,"code":201,"type":150,"language":151,"highlight":202},"b20","let pollId = null;\n\nfunction startPolling() {\n  pollId = setInterval(fetchLatest, 2000);\n}\n\nfunction stopPolling() {\n  clearInterval(pollId);\n  pollId = null;\n}\n\ndocument.addEventListener(\"visibilitychange\", () => {\n  if (document.hidden) {\n    stopPolling();\n  } else if (!pollId) {\n    startPolling();\n  }\n});\n\nif (!document.hidden) startPolling();",[],{"id":204,"html":205,"type":136},"b21","\u003Cp>That&#39;s the whole change. But there&#39;s a second gotcha waiting even after you&#39;ve picked the right event: don&#39;t trust the \u003Cem>tick count\u003C\u002Fem> to tell you how long the tab was gone.\u003C\u002Fp>",{"id":207,"html":208,"type":136},"b22","\u003Cp>Browsers throttle timers running in tabs nobody can see — it&#39;s a deliberate battery-saving move, and it means a \u003Ccode>setInterval\u003C\u002Fcode> you started before the tab was hidden won&#39;t necessarily fire on schedule while it&#39;s backgrounded. If your &quot;time away&quot; logic counts ticks (\u003Ccode>ticksElapsed * intervalMs\u003C\u002Fcode>), a long background stretch will under-report itself, because some of those ticks simply never fired.\u003C\u002Fp>",{"id":210,"html":211,"type":136},"b23","\u003Cp>The fix is to stop counting ticks and start reading the clock. Stamp \u003Ccode>Date.now()\u003C\u002Fcode> on the way out and the way back in:\u003C\u002Fp>",{"id":213,"code":214,"type":150,"language":151,"highlight":215},"b24","let hiddenAt = null;\n\ndocument.addEventListener(\"visibilitychange\", () => {\n  if (document.hidden) {\n    hiddenAt = Date.now();\n  } else if (hiddenAt) {\n    const hiddenMs = Date.now() - hiddenAt;\n    reconcile(hiddenMs); \u002F\u002F catch up state using real elapsed time\n    hiddenAt = null;\n  }\n});",[],{"id":217,"html":218,"type":136},"b25","\u003Cp>\u003Ccode>visibilitychange\u003C\u002Fcode> gives you the exact moments; wall-clock math gives you the exact duration between them. Neither depends on how many timer callbacks the browser decided to actually run in between.\u003C\u002Fp>",{"id":220,"html":221,"type":136},"b26","\u003C!-- quiz:start -->",{"id":223,"html":224,"text":224,"type":143,"level":31},"b27","🧠 Test yourself",{"id":226,"html":227,"type":136},"b28","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":229,"html":230,"type":136},"b29","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":232,"html":233,"type":136},"b30","\u003C!-- quiz:end -->",{"id":235,"html":236,"text":236,"type":143,"level":31},"b31","Focus and visibility are different questions — use the one you mean",{"id":238,"html":239,"type":136},"b32","\u003Cp>The rule of thumb: \u003Ccode>focus\u003C\u002Fcode>\u002F\u003Ccode>blur\u003C\u002Fcode> for &quot;does this element or window currently have keyboard input&quot; — form fields, keyboard shortcuts, anything about \u003Cem>input\u003C\u002Fem>. \u003Ccode>visibilitychange\u003C\u002Fcode> for &quot;can a human currently see this&quot; — video and animation playback, polling and refresh intervals, analytics dwell time, autosave cadence. It&#39;s also the more reliable place to flush a last-gasp analytics beacon than \u003Ccode>beforeunload\u003C\u002Fcode>, since \u003Ccode>hidden\u003C\u002Fcode> fires on tab close too, and far more consistently across mobile browsers.\u003C\u002Fp>",{"id":241,"html":242,"type":136},"b33","\u003Cp>Confusing the two isn&#39;t a syntax error. It&#39;s a background video that stops for the wrong reason, and a poll that never stops for the right one.\u003C\u002Fp>",{"id":244,"html":245,"type":136},"b34","\u003Cp>What&#39;s ticking away in one of your background tabs right now that has no idea nobody&#39;s watching?\u003C\u002Fp>",{"id":247,"type":248},"b35","divider",{"id":250,"html":251,"type":136},"b36","\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":253,"html":254,"type":136},"b37","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":256,"type":170,"items":257,"ordered":18},"b38",[258,259,260],"⭐ \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>","Picture a dashboard tile polling an endpoint every two seconds to keep a number fresh. You switch to Slack to answer a message. Four minutes later you're still there, and the tile is still polling — a request every two seconds, into a tab nobody has looked at since you left it.\n\nMultiply that by however many tabs your users leave open, and it's not a rounding error. It's server load and battery drain for work that produces zero value, because the one piece of information that would have stopped it — *is anyone actually looking at this* — was never checked.\n\n## The fix that feels obvious\n\nThe instinct is to pause on `blur` and resume on `focus`. It reads clean:\n\n```js\nwindow.addEventListener(\"blur\", () => clearInterval(pollId));\nwindow.addEventListener(\"focus\", () => { pollId = startPolling(); });\n```\n\nShip that, and eventually someone files a strange bug: a video that's supposed to keep playing in the background pauses when they click into DevTools. Or a live ticker that stops updating the instant they open a browser extension's popup. Or — the one that's easy to miss because it only happens on multi-monitor setups — a dashboard that \"goes idle\" while it's sitting fully visible on a second screen, because the user is typing in a different app on their main monitor.\n\nNone of those tabs are hidden. The content is right there on the glass. `blur` fired anyway, because `blur` and `focus` answer a narrower question than the one you actually asked.\n\n## The question you meant to ask\n\n`blur`\u002F`focus` tell you whether a window has *keyboard focus*. That's genuinely useful for things like \"should this input show its focus ring\" — it is not the same as \"can a human currently see this content.\" A window can lose focus while staying fully rendered and visible; that's exactly the second-monitor case above, and it's why MDN's own guidance singles this out when explaining why the Page Visibility API exists at all, rather than just reusing focus events.\n\nThe API that answers the real question is small:\n\n- `document.visibilityState` — `\"visible\"` or `\"hidden\"`.\n- `document.hidden` — the older boolean shorthand for the same thing.\n- the `visibilitychange` event, fired on `document` whenever that state flips.\n\nIt goes \"hidden\" when the tab is switched away from, the window is minimized, or (on most platforms) the screen locks — the set of conditions where the content is, as far as the browser can tell, definitely not on anyone's retina. It stays `\"visible\"` in the second-monitor case above, because it correctly is. This has been standard and unprefixed in every evergreen browser for well over a decade — there's no feature-detection dance required.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Wiring it up properly\n\nSwap the listener, not just the vocabulary:\n\n```js\nlet pollId = null;\n\nfunction startPolling() {\n  pollId = setInterval(fetchLatest, 2000);\n}\n\nfunction stopPolling() {\n  clearInterval(pollId);\n  pollId = null;\n}\n\ndocument.addEventListener(\"visibilitychange\", () => {\n  if (document.hidden) {\n    stopPolling();\n  } else if (!pollId) {\n    startPolling();\n  }\n});\n\nif (!document.hidden) startPolling();\n```\n\nThat's the whole change. But there's a second gotcha waiting even after you've picked the right event: don't trust the *tick count* to tell you how long the tab was gone.\n\nBrowsers throttle timers running in tabs nobody can see — it's a deliberate battery-saving move, and it means a `setInterval` you started before the tab was hidden won't necessarily fire on schedule while it's backgrounded. If your \"time away\" logic counts ticks (`ticksElapsed * intervalMs`), a long background stretch will under-report itself, because some of those ticks simply never fired.\n\nThe fix is to stop counting ticks and start reading the clock. Stamp `Date.now()` on the way out and the way back in:\n\n```js\nlet hiddenAt = null;\n\ndocument.addEventListener(\"visibilitychange\", () => {\n  if (document.hidden) {\n    hiddenAt = Date.now();\n  } else if (hiddenAt) {\n    const hiddenMs = Date.now() - hiddenAt;\n    reconcile(hiddenMs); \u002F\u002F catch up state using real elapsed time\n    hiddenAt = null;\n  }\n});\n```\n\n`visibilitychange` gives you the exact moments; wall-clock math gives you the exact duration between them. Neither depends on how many timer callbacks the browser decided to actually run in between.\n\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers\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## Focus and visibility are different questions — use the one you mean\n\nThe rule of thumb: `focus`\u002F`blur` for \"does this element or window currently have keyboard input\" — form fields, keyboard shortcuts, anything about *input*. `visibilitychange` for \"can a human currently see this\" — video and animation playback, polling and refresh intervals, analytics dwell time, autosave cadence. It's also the more reliable place to flush a last-gasp analytics beacon than `beforeunload`, since `hidden` fires on tab close too, and far more consistently across mobile browsers.\n\nConfusing the two isn't a syntax error. It's a background video that stops for the wrong reason, and a poll that never stops for the right one.\n\nWhat's ticking away in one of your background tabs right now that has no idea nobody's watching?\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":108,"canonical":263,"description":264},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers","window blur feels like the obvious way to detect an idle tab, and it's wrong in ways that only show up in production. The Page Visibility API answers the actual question — can anyo","01a057f2-68b2-7521-a373-13d30cdd3d60",{"id":267,"locked":18},"01a057f2-68d2-70a9-893d-64c36451d91d",[269],{"id":45,"slug":46,"title":48,"_count":270},{"questions":51},[272],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":274,"questionCount":51},{"questions":51}]