[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-requestidlecallback-can-wait-forever":44,"search-suggestions":60,"quiz-article-requestidlecallback-can-wait-forever":107},[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},"01a0ddae-3c35-71f3-a312-00da092983fb","requestidlecallback-can-wait-forever","PRACTICE_QUIZ","requestIdleCallback: how idle is 'idle'?","Ten questions on what requestIdleCallback actually schedules, why it can stall indefinitely, what the timeout option really trades away, and when to reach for something else entirely.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},10,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,96,99,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",111,{"slug":66,"name":67,"articles":68},"javascript","Javascript",93,{"slug":70,"name":71,"articles":72},"frontend","Frontend",73,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",39,{"slug":78,"name":79,"articles":80},"css","Css",36,{"slug":82,"name":83,"articles":84},"typescript","Typescript",16,{"slug":86,"name":87,"articles":88},"performance","Performance",14,{"slug":90,"name":91,"articles":92},"react","React",13,{"slug":94,"name":95,"articles":51},"browser","Browser",{"slug":97,"name":98,"articles":51},"node","Node",{"slug":100,"name":101,"articles":102},"html","Html",8,{"slug":104,"name":105,"articles":106},"accessibility","Accessibility",7,{"id":108,"slug":46,"title":109,"subtitle":52,"excerpt":110,"coverUrl":111,"locale":13,"readingMinutes":112,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":123,"_count":128,"playground":130,"body":132,"bodyMd":297,"seo":298,"translationGroupId":301,"series":52,"podcastUrl":52,"verticalId":5,"thread":302,"assessments":304,"translations":307,"quiz":309},"01a0ddae-3baa-7278-a8e5-48e21a2fe826","The Background Task That Waited 40 Seconds for 'Idle'","requestIdleCallback promises to run only when the browser has spare cycles — but on a page that's never truly idle, that promise has no upper bound. Here's why the callback can stall, why the timeout escape hatch has its own trap, and when to reach for it instead of setTimeout or scheduler.yield.","\u002Fmedia\u002Fcovers\u002Frequestidlecallback-can-wait-forever.png",6,"2026-09-26T12:24:05.529Z",40,{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":122},"JavaScript",[124,125,126,127],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":86,"name":87,"color":52},{"slug":70,"name":71,"color":52},{"assessments":129},1,{"slug":46,"title":131},"requestIdleCallback vs. a busy page — interactive playground",{"blocks":133,"version":129},[134,138,141,144,148,151,154,160,163,167,170,173,176,179,182,185,188,191,194,198,201,205,208,211,215,218,221,225,228,232,235,241,244,248,251,255,258,261,264,267,270,273,276,279,282,285,288,291],{"id":135,"html":136,"type":137},"b1","\u003Cp>You wire up \u003Ccode>requestIdleCallback\u003C\u002Fcode> 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.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>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&#39;t save for 40 seconds. Sometimes it doesn&#39;t save at all before they close the tab.\u003C\u002Fp>",{"id":142,"html":143,"type":137},"b3","\u003Cp>Nothing crashed. Nothing errored. The callback just never got a moment it was willing to call &quot;idle.&quot;\u003C\u002Fp>",{"id":145,"html":146,"text":146,"type":147,"level":31},"b4","What you probably think it does","heading",{"id":149,"html":150,"type":137},"b5","\u003Cp>The name reads like a promise: schedule this, and the browser will run it soon, whenever it has a spare cycle. That&#39;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.\u003C\u002Fp>",{"id":152,"html":153,"type":137},"b6","\u003Cp>Here&#39;s the actual shape of the API:\u003C\u002Fp>",{"id":155,"code":156,"type":157,"language":158,"highlight":159},"b7","requestIdleCallback((deadline) => {\n  console.log(deadline.timeRemaining()); \u002F\u002F ms left in this idle period\n  console.log(deadline.didTimeout);       \u002F\u002F true if forced by the timeout option\n});","code","js",[],{"id":161,"html":162,"type":137},"b8","\u003Cp>\u003Ccode>timeRemaining()\u003C\u002Fcode> tells you how much of the current idle slice is left — usually a number under 50, sometimes 0. \u003Ccode>didTimeout\u003C\u002Fcode> tells you whether the browser actually found idle time or gave up and ran you anyway. That second field exists because the first promise — &quot;I&#39;ll call you when I&#39;m free&quot; — has no deadline attached to it at all.\u003C\u002Fp>",{"id":164,"html":165,"text":166,"type":147,"level":31},"b9","Idle time isn&#39;t a clock. It&#39;s a gap.","Idle time isn't a clock. It's a gap.",{"id":168,"html":169,"type":137},"b10","\u003Cp>The browser looks for idle periods between frames: after it&#39;s finished painting, handling input, and running its own housekeeping for that frame, whatever&#39;s left over becomes an idle window. If there&#39;s nothing left over — because something is animating, a timer fires every 16ms, or the user is mid-scroll — there&#39;s no window, and your callback simply doesn&#39;t run.\u003C\u002Fp>",{"id":171,"html":172,"type":137},"b11","\u003Cp>Crucially, there&#39;s no ceiling on how long that can go on. A page that keeps finding \u003Cem>something\u003C\u002Fem> to do every frame can starve every \u003Ccode>requestIdleCallback\u003C\u002Fcode> on it indefinitely. Not &quot;eventually, slowly&quot; — indefinitely, until the page actually goes quiet.\u003C\u002Fp>",{"id":174,"html":175,"type":137},"b12","\u003Cp>Watch it happen in your own browser: turn up the simulated load below and watch the scheduled callback stop firing.\u003C\u002Fp>",{"id":177,"html":178,"type":137},"b13","\u003C!-- playground:start -->",{"id":180,"html":181,"text":181,"type":147,"level":31},"b14","🎮 Try it yourself",{"id":183,"html":184,"type":137},"b15","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestidlecallback-can-wait-forever\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":186,"html":187,"type":137},"b16","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":189,"html":190,"type":137},"b17","\u003C!-- playground:end -->",{"id":192,"html":193,"type":137},"b18","\u003Cp>That slider isn&#39;t decorative — it&#39;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.\u003C\u002Fp>",{"id":195,"html":196,"text":197,"type":147,"level":31},"b19","The timeout option doesn&#39;t fix this — it changes what breaks","The timeout option doesn't fix this — it changes what breaks",{"id":199,"html":200,"type":137},"b20","\u003Cp>\u003Ccode>requestIdleCallback\u003C\u002Fcode> takes a second argument for exactly this problem:\u003C\u002Fp>",{"id":202,"code":203,"type":157,"language":158,"highlight":204},"b21","requestIdleCallback(saveTasks, { timeout: 2000 });",[],{"id":206,"html":207,"type":137},"b22","\u003Cp>This says: \u003Cem>if you haven&#39;t found idle time within 2000ms, run it anyway.\u003C\u002Fem> That sounds like the fix — a guaranteed upper bound. It is, but read what you actually got: the browser will now interrupt whatever it&#39;s doing to run your callback, \u003Ccode>didTimeout\u003C\u002Fcode> will be \u003Ccode>true\u003C\u002Fcode>, and \u003Ccode>timeRemaining()\u003C\u002Fcode> inside it will report \u003Ccode>0\u003C\u002Fcode>. You didn&#39;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.\u003C\u002Fp>",{"id":209,"html":210,"type":137},"b23","\u003Cp>If that callback does anything nontrivial — serializing a large object, walking a DOM tree — a \u003Ccode>timeout\u003C\u002Fcode>-forced run can itself become the janky frame you were trying to avoid. The option trades &quot;might never run&quot; for &quot;might run at the worst possible moment.&quot; Pick your poison deliberately, per call site, instead of reaching for \u003Ccode>timeout: 2000\u003C\u002Fcode> as a reflex.\u003C\u002Fp>",{"id":212,"html":213,"text":214,"type":147,"level":31},"b24","It isn&#39;t Baseline — and the polyfill people write is a footgun","It isn't Baseline — and the polyfill people write is a footgun",{"id":216,"html":217,"type":137},"b25","\u003Cp>\u003Ccode>requestIdleCallback\u003C\u002Fcode> ships in Chromium and Firefox. \u003Cstrong>Safari has never implemented it.\u003C\u002Fstrong> WebKit&#39;s objections to the original design are old and well documented, and there&#39;s no sign of that changing, so treat this the same way you&#39;d treat any non-Baseline API: with a fallback, always.\u003C\u002Fp>",{"id":219,"html":220,"type":137},"b26","\u003Cp>The fallback people reach for is \u003Ccode>setTimeout\u003C\u002Fcode>, and it&#39;s worth writing carefully:\u003C\u002Fp>",{"id":222,"code":223,"type":157,"language":158,"highlight":224},"b27","const requestIdle =\n  window.requestIdleCallback ??\n  ((cb) =>\n    setTimeout(() => {\n      const start = Date.now();\n      cb({\n        didTimeout: false,\n        timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),\n      });\n    }, 1));\n\nconst cancelIdle = window.cancelIdleCallback ?? clearTimeout;",[],{"id":226,"html":227,"type":137},"b28","\u003Cp>Note this is a \u003Cem>shim for the shape of the API\u003C\u002Fem>, not a real implementation of idle detection — \u003Ccode>setTimeout\u003C\u002Fcode> has no idea whether the main thread is actually free. In Safari, code gated on \u003Ccode>requestIdleCallback\u003C\u002Fcode> runs on a timer instead, with no idle guarantee at all. That&#39;s usually fine for genuinely optional work, and it&#39;s exactly why the work you defer to this API should be optional in the first place.\u003C\u002Fp>",{"id":229,"html":230,"text":231,"type":147,"level":31},"b29","Not the same job as \u003Ccode>scheduler.yield\u003C\u002Fcode>","Not the same job as scheduler.yield",{"id":233,"html":234,"type":137},"b30","\u003Cp>If you&#39;ve read about \u003Ccode>scheduler.yield()\u003C\u002Fcode>, it&#39;s easy to lump these together as &quot;ways to be nice to the main thread.&quot; They solve opposite problems:\u003C\u002Fp>",{"id":236,"type":237,"items":238,"ordered":18},"b31","list",[239,240],"\u003Cstrong>\u003Ccode>scheduler.yield()\u003C\u002Fcode>\u003C\u002Fstrong> 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.","\u003Cstrong>\u003Ccode>requestIdleCallback\u003C\u002Fcode>\u003C\u002Fstrong> is for work you&#39;d like to finish eventually, only if nothing more important is happening — and you&#39;re explicitly fine with &quot;eventually&quot; meaning &quot;maybe not for a while.&quot;",{"id":242,"html":243,"type":137},"b32","\u003Cp>Use \u003Ccode>scheduler.yield()\u003C\u002Fcode> (or its \u003Ccode>setTimeout(0)\u003C\u002Fcode> fallback) for a big loop that has to complete. Use \u003Ccode>requestIdleCallback\u003C\u002Fcode> 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&#39;s asked for yet.\u003C\u002Fp>",{"id":245,"html":246,"text":247,"type":147,"level":31},"b33","The fix for the draft that didn&#39;t save","The fix for the draft that didn't save",{"id":249,"html":250,"type":137},"b34","\u003Cp>Back to the autosave. The bug wasn&#39;t calling \u003Ccode>requestIdleCallback\u003C\u002Fcode> — it was making it the \u003Cem>only\u003C\u002Fem> 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):\u003C\u002Fp>",{"id":252,"code":253,"type":157,"language":158,"highlight":254},"b35","function scheduleAutosave(draft) {\n  requestIdle(() => saveDraft(draft), { timeout: 1500 });\n}\n\n\u002F\u002F Belt and suspenders: this one is not allowed to wait for \"idle.\"\nwindow.addEventListener(\"pagehide\", () => {\n  navigator.sendBeacon(\"\u002Fapi\u002Fdrafts\", JSON.stringify(pendingDraft));\n});",[],{"id":256,"html":257,"type":137},"b36","\u003Cp>\u003Ccode>requestIdleCallback\u003C\u002Fcode> handles the frequent, low-stakes saves. \u003Ccode>pagehide\u003C\u002Fcode> and \u003Ccode>sendBeacon\u003C\u002Fcode> handle the one save that&#39;s actually load-bearing, and they don&#39;t ask the browser&#39;s permission first.\u003C\u002Fp>",{"id":259,"html":260,"type":137},"b37","\u003C!-- quiz:start -->",{"id":262,"html":263,"text":263,"type":147,"level":31},"b38","🧠 Test yourself",{"id":265,"html":266,"type":137},"b39","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestidlecallback-can-wait-forever\u002Fquiz\">Take the 10-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":268,"html":269,"type":137},"b40","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":271,"html":272,"type":137},"b41","\u003C!-- quiz:end -->",{"id":274,"html":275,"text":275,"type":147,"level":31},"b42","The takeaway",{"id":277,"html":278,"type":137},"b43","\u003Cp>\u003Ccode>requestIdleCallback\u003C\u002Fcode> isn&#39;t a scheduler that guarantees a turn — it&#39;s a favor you ask the browser, one it&#39;s allowed to keep declining as long as the page stays busy. Use it for work you&#39;re genuinely willing to skip, add a \u003Ccode>timeout\u003C\u002Fcode> only when you&#39;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.\u003C\u002Fp>",{"id":280,"html":281,"type":137},"b44","\u003Cp>Where have you used \u003Ccode>requestIdleCallback\u003C\u002Fcode> — and did you give it a real fallback, or trust it to always eventually fire? I&#39;m curious how many of us have a draft-save bug like this one sitting in production right now.\u003C\u002Fp>",{"id":283,"type":284},"b45","divider",{"id":286,"html":287,"type":137},"b46","\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":289,"html":290,"type":137},"b47","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":292,"type":237,"items":293,"ordered":18},"b48",[294,295,296],"⭐ \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>","You wire up `requestIdleCallback` 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.\n\nThen 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.\n\nNothing crashed. Nothing errored. The callback just never got a moment it was willing to call \"idle.\"\n\n## What you probably think it does\n\nThe 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.\n\nHere's the actual shape of the API:\n\n```js\nrequestIdleCallback((deadline) => {\n  console.log(deadline.timeRemaining()); \u002F\u002F ms left in this idle period\n  console.log(deadline.didTimeout);       \u002F\u002F true if forced by the timeout option\n});\n```\n\n`timeRemaining()` tells you how much of the current idle slice is left — usually a number under 50, sometimes 0. `didTimeout` 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.\n\n## Idle time isn't a clock. It's a gap.\n\nThe 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.\n\nCrucially, there's no ceiling on how long that can go on. A page that keeps finding *something* to do every frame can starve every `requestIdleCallback` on it indefinitely. Not \"eventually, slowly\" — indefinitely, until the page actually goes quiet.\n\nWatch it happen in your own browser: turn up the simulated load below and watch the scheduled callback stop firing.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestidlecallback-can-wait-forever\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\nThat 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.\n\n## The timeout option doesn't fix this — it changes what breaks\n\n`requestIdleCallback` takes a second argument for exactly this problem:\n\n```js\nrequestIdleCallback(saveTasks, { timeout: 2000 });\n```\n\nThis says: *if you haven't found idle time within 2000ms, run it anyway.* 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, `didTimeout` will be `true`, and `timeRemaining()` inside it will report `0`. 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.\n\nIf that callback does anything nontrivial — serializing a large object, walking a DOM tree — a `timeout`-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 `timeout: 2000` as a reflex.\n\n## It isn't Baseline — and the polyfill people write is a footgun\n\n`requestIdleCallback` ships in Chromium and Firefox. **Safari has never implemented it.** 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.\n\nThe fallback people reach for is `setTimeout`, and it's worth writing carefully:\n\n```js\nconst requestIdle =\n  window.requestIdleCallback ??\n  ((cb) =>\n    setTimeout(() => {\n      const start = Date.now();\n      cb({\n        didTimeout: false,\n        timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),\n      });\n    }, 1));\n\nconst cancelIdle = window.cancelIdleCallback ?? clearTimeout;\n```\n\nNote this is a *shim for the shape of the API*, not a real implementation of idle detection — `setTimeout` has no idea whether the main thread is actually free. In Safari, code gated on `requestIdleCallback` 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.\n\n## Not the same job as `scheduler.yield`\n\nIf you've read about `scheduler.yield()`, it's easy to lump these together as \"ways to be nice to the main thread.\" They solve opposite problems:\n\n- **`scheduler.yield()`** 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.\n- **`requestIdleCallback`** 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.\"\n\nUse `scheduler.yield()` (or its `setTimeout(0)` fallback) for a big loop that has to complete. Use `requestIdleCallback` 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.\n\n## The fix for the draft that didn't save\n\nBack to the autosave. The bug wasn't calling `requestIdleCallback` — it was making it the *only* 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):\n\n```js\nfunction scheduleAutosave(draft) {\n  requestIdle(() => saveDraft(draft), { timeout: 1500 });\n}\n\n\u002F\u002F Belt and suspenders: this one is not allowed to wait for \"idle.\"\nwindow.addEventListener(\"pagehide\", () => {\n  navigator.sendBeacon(\"\u002Fapi\u002Fdrafts\", JSON.stringify(pendingDraft));\n});\n```\n\n`requestIdleCallback` handles the frequent, low-stakes saves. `pagehide` and `sendBeacon` handle the one save that's actually load-bearing, and they don't ask the browser's permission first.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 10-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestidlecallback-can-wait-forever\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\n## The takeaway\n\n`requestIdleCallback` 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 `timeout` 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.\n\nWhere have you used `requestIdleCallback` — 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.\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":109,"canonical":299,"description":300},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestidlecallback-can-wait-forever","requestIdleCallback promises to run only when the browser has spare cycles — but on a page that's never truly idle, that promise has no upper bound. Here's why the callback can sta","01a0ddae-3bab-7509-a927-9a4ee31d5f0a",{"id":303,"locked":18},"01a0ddae-3bec-742f-a155-4c16a1871a6a",[305],{"id":45,"slug":46,"title":48,"_count":306},{"questions":51},[308],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":310,"questionCount":51},{"questions":51}]