Scheduling Non-Critical Work with requestIdleCallback
Scheduling Non-Critical Work with requestIdleCallback
The Symptom
The e-commerce product listing page loads three analytics scripts, initializes a recommendation engine, and prefetches data for the next likely navigation. These tasks execute during page load, adding 340ms of main thread work that competes with rendering and user interaction handling.
None of these tasks need to complete before the user can interact with the page. The recommendation engine result is not visible until the user scrolls to the bottom. The analytics scripts track page views that are valid whenever they fire. The prefetch benefits future navigations, not the current one.
Yet all three execute synchronously during the initial load, pushing INP from 120ms to 320ms for users who interact within the first 2 seconds.
The Cause
Without explicit scheduling, the browser executes JavaScript in the order it encounters it. Scripts in the <head> run before the body renders. Dynamic imports resolve and execute as soon as the module is available. useEffect callbacks in React fire after paint but still compete for the main thread.
The browser has idle periods between user interactions and rendering work. On a page that has finished loading, the main thread is idle 70-90% of the time. During page load, idle periods are shorter but still exist between long tasks. requestIdleCallback lets you schedule work into these idle periods.
The Baseline
Main thread timeline during product listing page load (throttled):
0-40ms: HTML parse
40-380ms: Main JS bundle evaluation (long task: 340ms)
380-600ms: React render and hydration (long task: 220ms)
600-640ms: Layout and paint
640-680ms: Analytics script 1 init (40ms)
680-760ms: Analytics script 2 init (80ms)
760-820ms: Recommendation engine init (60ms)
820-980ms: Prefetch requests + processing (160ms)
Total main thread busy time: 980ms. The page is visually complete at 640ms, but the main thread remains occupied for another 340ms with non-essential work. A user interaction at 700ms would be queued behind the analytics and recommendation work.
The Fix
Defer non-critical initialization to idle periods:
// SLOW: All initialization runs synchronously after page load
function initializePage(): void {
initAnalyticsScript1(); // 40ms
initAnalyticsScript2(); // 80ms
initRecommendationEngine(); // 60ms
prefetchNextPageData(); // 160ms
}
// FAST: Critical work runs immediately, non-critical deferred
function initializePage(): void {
// Only critical initialization runs synchronously
// (none in this case - all are non-critical)
scheduleIdleWork([
{ fn: initAnalyticsScript1, name: "analytics-1" },
{ fn: initAnalyticsScript2, name: "analytics-2" },
{ fn: initRecommendationEngine, name: "recommendations" },
{ fn: prefetchNextPageData, name: "prefetch" },
]);
}
interface IdleTask {
fn: () => void;
name: string;
}
function scheduleIdleWork(tasks: IdleTask[]): void {
let index = 0;
function processNext(deadline: IdleDeadline): void {
while (index < tasks.length && deadline.timeRemaining() > 5) {
const task = tasks[index];
try {
task.fn();
} catch (error) {
console.error(`Idle task "${task.name}" failed:`, error);
}
index++;
}
if (index < tasks.length) {
requestIdleCallback(processNext, { timeout: 5000 });
}
}
if ("requestIdleCallback" in window) {
requestIdleCallback(processNext, { timeout: 5000 });
} else {
// Fallback: run after a delay
setTimeout(() => {
for (const task of tasks) {
try {
task.fn();
} catch (error) {
console.error(`Idle task "${task.name}" failed:`, error);
}
}
}, 2000);
}
}
The deadline.timeRemaining() > 5 check ensures each task runs only when at least 5ms of idle time remains. If a task takes longer than the remaining idle time, it may run slightly over, but the next task will wait for the next idle period.
The timeout: 5000 option sets a maximum wait time. If the browser never has an idle period within 5 seconds (unlikely but possible on very busy pages), the callback fires anyway. This prevents analytics from being indefinitely deferred.
For the Scheduler API (available behind a flag in Chrome, likely to become stable):
// Using scheduler.postTask for priority-based scheduling
async function initializePageWithPriority(): Promise<void> {
const scheduler = (globalThis as any).scheduler;
if (!scheduler?.postTask) {
// Fallback to requestIdleCallback approach above
return;
}
// User-blocking: must complete for core functionality
// (nothing in this case)
// User-visible: should complete soon but not blocking
await scheduler.postTask(initRecommendationEngine, {
priority: "user-visible",
});
// Background: complete whenever the browser has time
scheduler.postTask(initAnalyticsScript1, {
priority: "background",
});
scheduler.postTask(initAnalyticsScript2, {
priority: "background",
});
scheduler.postTask(prefetchNextPageData, {
priority: "background",
});
}
The three priority levels (user-blocking, user-visible, background) give the browser more information about scheduling than requestIdleCallback alone. background tasks run at the lowest priority, similar to requestIdleCallback. user-visible tasks run at a higher priority, suitable for work that affects the next screen the user will see.
The Proof
Main thread timeline after deferring non-critical work:
0-40ms: HTML parse
40-380ms: Main JS bundle evaluation (long task: 340ms)
380-600ms: React render and hydration (long task: 220ms)
600-640ms: Layout and paint
640-980ms: IDLE (user can interact without contention)
Idle tasks run in gaps: analytics at ~700ms,
recommendations at ~750ms, prefetch at ~850ms
The page becomes interactive at 640ms instead of 980ms. The non-critical work still completes within the first second, but it runs in idle periods rather than blocking the main thread.
| Metric | Before | After | Delta |
|---|---|---|---|
| Time to Interactive | 980ms | 640ms | -340ms |
| INP (interaction at 700ms) | 320ms | 18ms | -302ms |
| Analytics init completion | 760ms | 700ms | -60ms |
| All non-critical work done | 980ms | 980ms | 0ms |
The total time to complete all work is unchanged. The difference is entirely in when the work runs relative to user interactions. The 302ms INP improvement comes from moving analytics initialization out of the main thread’s critical window.
The CI performance gate from Chapter 2 captures this improvement through the Total Blocking Time metric. TBT dropped from 640ms to 340ms because the deferred tasks no longer contribute to blocking time during the measurement window.
The Trade-off
requestIdleCallback has no guaranteed execution time. On a page where the user continuously interacts (scrolling, typing, clicking), idle periods may not occur for several seconds. For analytics, a 2-3 second delay in initialization is acceptable. For a feature that the user expects to see quickly (the recommendation carousel), the delay might be noticeable.
The timeout parameter mitigates this by forcing execution after the specified duration, but this defeats the purpose of idle scheduling if the timeout is too short. The 5-second timeout used here is a compromise: long enough to wait for genuine idle periods, short enough to ensure analytics fire before the user leaves the page.
Safari does not support requestIdleCallback. The fallback using setTimeout(fn, 2000) is crude but functional: it delays non-critical work by 2 seconds, which is long enough for most pages to complete initial rendering. The Scheduler API (scheduler.postTask) is the intended replacement, with broader browser support expected as the specification stabilizes.
The code complexity cost is the queue management logic. The scheduleIdleWork function is approximately 30 lines of TypeScript that must be tested and maintained. For applications with 2-3 non-critical initialization tasks, this is proportional. For applications with dozens of deferred tasks, consider a task manager library rather than a custom implementation.