Scheduler API in Practice: Browser Task Priority Scheduling and Main Thread Optimization
The Main Thread Scheduling Dilemma
The browser main thread handles UI rendering, event processing, and JavaScript execution simultaneously. Long tasks (>50ms) block interaction and degrade INP:
| Traditional Approach | Problem |
|---|---|
| setTimeout(fn, 0) | Actual delay 4ms, no priority control |
| requestIdleCallback | Only runs when idle, no timeliness guarantee |
| await scheduler.yield() | ✅ Precise yielding, maintains scheduling order |
| scheduler.postTask() | ✅ Priority + yielding + cancellation = complete scheduling |
Scheduler API Priority Model
scheduler.postTask() provides three priority levels mapped to browser internal scheduling:
| Priority | Meaning | Typical Use | Chrome Internal Mapping |
|---|---|---|---|
user-blocking |
Highest, execute immediately | User input response, animation | Very High |
user-visible |
Medium, execute soon | DOM updates, data parsing | High / Medium |
background |
Lowest, execute when idle | Analytics, pre-computation | Low / Best Effort |
Basic Usage
const result = await scheduler.postTask(
() => heavyComputation(data),
{ priority: 'background' }
);
Practice: Priority Scheduling for Image Compression
ToolsKu's Image Compress needs to respond to user interaction while batch-processing images:
async function handleBatchCompress(files: File[]) {
const controller = new TaskController({ priority: 'background' });
for (const file of files) {
// Low priority: compression doesn't block UI
scheduler.postTask(
async () => {
const result = await compressImage(file, { quality: 0.85 });
// Medium priority: update DOM so user sees progress
await scheduler.postTask(
() => updateProgressUI(file.name, result),
{ priority: 'user-visible' }
);
},
{ signal: controller.signal }
);
}
return controller;
}
When the user clicks "Cancel", controller.abort() immediately stops all pending tasks.
scheduler.yield(): Precise Main Thread Yielding
scheduler.yield() is more precise than setTimeout(fn, 0)—it yields to higher-priority tasks while maintaining execution order:
async function processLargeDataset(items: Item[]) {
const results: Result[] = [];
for (let i = 0; i < items.length; i++) {
results.push(transform(items[i]));
// Yield every 50 items to avoid long tasks
if (i % 50 === 0) {
await scheduler.yield();
}
}
return results;
}
yield() vs setTimeout() Comparison
| Feature | setTimeout(fn, 0) | scheduler.yield() |
|---|---|---|
| Minimum delay | 4ms | 0ms |
| Priority-aware | ❌ No | ✅ Yields to higher priority |
| Execution order | Not guaranteed | ✅ Maintains scheduling order |
| Long task splitting | ✅ | ✅ |
| Browser support | All | Chrome 115+ |
TaskController: Cancellation and Dynamic Priority
const controller = new TaskController({ priority: 'user-visible' });
// Submit task
scheduler.postTask(() => renderChart(data), { signal: controller.signal });
// Dynamic downgrade: lower priority when tab is hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
controller.setPriority('background');
} else {
controller.setPriority('user-visible');
}
});
// Cancel all tasks
controller.abort();
Strategies for Avoiding Long Tasks
Strategy 1: Chunking + yield
async function chunkedProcess<T>(items: T[], fn: (item: T) => void, chunkSize = 20) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(fn);
await scheduler.yield();
}
}
Strategy 2: Priority Layering
function scheduleWork(work: WorkItem) {
const priority = work.type === 'user-input' ? 'user-blocking'
: work.type === 'render' ? 'user-visible'
: 'background';
return scheduler.postTask(work.execute, { priority });
}
Strategy 3: AbortController Integration
const ac = new AbortController();
scheduler.postTask(() => mergePDFs(files), {
priority: 'background',
signal: ac.signal
});
// Cancel when user navigates away
window.addEventListener('beforeunload', () => ac.abort());
Performance Benchmark: PDF Merge Scenario
Benchmark for PDF Merge processing 20 PDF files:
| Approach | Total Time | INP (ms) | Main Thread Blocking |
|---|---|---|---|
| Synchronous loop | 3.2s | 480 | 3.2s |
| setTimeout chunking | 3.3s | 120 | 0.4s |
| postTask + yield | 3.2s | 60 | 0.1s |
Total time unchanged, but INP drops from 480ms to 60ms—8x better interaction responsiveness.
Compatibility and Fallback
async function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
} else {
return new Promise(resolve => setTimeout(resolve, 0));
}
}
function postTask(fn: () => void, options?: { priority?: string }) {
if ('scheduler' in window) {
return scheduler.postTask(fn, options as any);
}
return Promise.resolve(fn());
}
Common Questions
postTask or requestIdleCallback?
requestIdleCallback only runs when the browser is idle—suitable for non-urgent work (e.g., preloading). postTask with background priority is similar but more controllable, with cancellation and dynamic priority adjustment.
Does yield() increase total time?
A single yield() costs ~0.1ms. Yielding every 50 items for 10,000 items adds only 20ms overhead—far less than the blocking time avoided.
How to debug priorities?
Chrome DevTools → Performance panel shows task priority annotations. You can also trace scheduling with scheduler.postTask(() => console.trace(), { priority: 'background' }).
Summary
The Scheduler API provides precise priority scheduling for the browser main thread. scheduler.postTask() enables three-level priority task dispatch, scheduler.yield() precisely yields the main thread to avoid long tasks, and TaskController supports cancellation and dynamic priority adjustment. This is essential infrastructure for building high-performance, highly responsive web applications.
Try these browser-local tools — no sign-up required →