Idle-Until-Urgent Animation Setup
Part of Main-Thread Scheduling & Long Animation Frames in Performance Budgeting & GPU Architecture.
The problem
A photo carousel builds its keyframes, measures slide widths and promotes the first three slides when the page loads — 40ms of work during the busiest moment of the page’s life, which inflates Total Blocking Time and delays the first interaction. Moving all of it into requestIdleCallback fixes the load metrics and introduces a new bug: a user who taps the carousel immediately gets a frozen first swipe, because the idle work had not run yet and now runs inside the interaction.
Setup work should happen early if there is time, and immediately if there is not.
Root cause analysis: idle work is optional until it is needed
Idle callbacks run when the browser has spare time. requestIdleCallback schedules work for the end of a frame that finished early, or during genuine idle periods. On a busy page it may not run for seconds — or at all before the user interacts.
Interactions cannot wait. When the user taps, the work must be done. If it has not run, the handler does it synchronously, and the cost lands inside the interaction.
“Idle until urgent” resolves the conflict. The pattern, popularised by Philip Walton, is to schedule the work at idle priority and expose a way to force it immediately. Whichever happens first wins, and the work runs exactly once. The value is memoised: an accessor either returns the already-computed value or computes it on the spot and cancels the pending idle task.
Priorities express the same idea in the scheduler API. scheduler.postTask(fn, { priority: 'background' }) runs after user-visible work; the returned task can be cancelled if the work becomes urgent and is run inline instead.
Not all setup is worth preparing. Promoting layers early costs memory for the whole waiting period. Precomputing keyframes is cheap and safe; promoting twenty slides “just in case” is not, which is the trade-off in will-change lifecycle.
Step-by-step resolution
Production code pattern
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
/** Runs `work` during idle time, or immediately when first requested. */
function idleUntilUrgent(work, { timeout = 2000 } = {}) {
let value, done = false, handle = 0;
const run = () => {
if (done) return value;
done = true;
if (handle) (self.cancelIdleCallback ?? clearTimeout)(handle);
value = work();
return value;
};
handle = (self.requestIdleCallback ?? ((fn) => setTimeout(fn, 200)))(run, { timeout });
return () => run(); // calling the accessor forces it
}
// Carousel: expensive setup prepared during idle, forced on first interaction.
const getCarouselSetup = idleUntilUrgent(() => {
const slides = [...track.children];
const widths = slides.map((s) => s.getBoundingClientRect().width); // one measurement pass
const keyframes = buildKeyframes(widths);
return { slides, widths, keyframes };
});
track.addEventListener('pointerdown', () => {
const { keyframes } = getCarouselSetup(); // instant if idle already ran it
track.dataset.animating = 'true'; // promotion happens now, not on load
startSwipe(keyframes);
}, { once: false });
track.addEventListener('transitionend', () => delete track.dataset.animating);
if (reduce.matches) {
// Nothing to prepare: the carousel jumps between slides.
track.dataset.reduced = 'true';
}
.carousel__track[data-animating] { will-change: transform; } /* promotion only while swiping */
@media (prefers-reduced-motion: reduce) {
.carousel__track { transition: none; }
}
Rendering Impact: none during idle beyond the setup itself; promotion is deferred to the interaction, so no texture memory is held while the user is reading. The interaction path is the same code, so there is one behaviour to test rather than two.
Passing a timeout to requestIdleCallback is what stops the work being deferred indefinitely on a busy page: after the timeout the callback runs at the next opportunity even without idle time.
Verification checklist
Constraints and trade-offs
requestIdleCallbackis not available in every browser; a short timeout fallback is close enough for this pattern.- Idle work still competes with rendering if it is long; break large setup into chunks.
- Memoised values can go stale if layout changes; invalidate on resize.
- Forcing work inside an interaction moves cost to the worst moment, so keep setup small enough that forcing it is acceptable.
- Preparing many components at idle can add up to a long idle task.
Frequently asked questions
What is the idle-until-urgent pattern?
Schedule work during idle time, but expose an accessor that runs it immediately if it is needed first. The work happens once, either way.
Why not just use requestIdleCallback?
On a busy page it may not run before the user interacts, so the cost lands inside the interaction instead — often the worst possible moment.
Should layer promotion be done during idle time?
No. Promotion holds texture memory for as long as it lasts. Promote when the interaction begins or when the element approaches the viewport.
Does requestIdleCallback have a timeout?
Yes, and it should be used. With a timeout the callback runs even if no idle time appears, which prevents work being deferred indefinitely.
Related
- Main-Thread Scheduling & Long Animation Frames — the parent topic
- Yielding to the Main Thread During Animations — splitting work that must run now
- Image Decoding and Animation Jank — decoding as idle preparation