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.

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.
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.
Nothing crashed. Nothing errored. The callback just never got a moment it was willing to call "idle."
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.
Here's the actual shape of the API:
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.
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.
Crucially, 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.
Watch it happen in your own browser: turn up the simulated load below and watch the scheduled callback stop firing.
Runs right in your browser — poke at it and watch the concept react live.
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.
requestIdleCallback takes a second argument for exactly this problem:
This 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.
If 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.
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.
The fallback people reach for is setTimeout, and it's worth writing carefully:
Note 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.
If 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:
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.requestIdleCallbackis 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."
Use 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.
Back 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):
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.
Think it clicked? Take the 10-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
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.
Where 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.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.