scheduler.yield() and Long Tasks: Unlock the Main Thread
The browser has only one main thread. All of the JavaScript runs in sequence on this thread your application, the layout and paint of the page, and the response to user input. The model of executing JavaScript e run-to-completion: a JavaScript task, once started, runs to the end without interruption. If that task takes 300ms, for those 300ms the browser can't do anything else: it doesn't respond to clicks, it doesn't update the interface, it doesn't process other events.
This is the fundamental problem that causes high NPI. The traditional solution was to use
setTimeout(fn, 0) o Promise.resolve().then() to "divide" the work,
but these approaches have prioritization problems and do not tell the browser the intent
to give up control. scheduler.yield() and the native solution
designed explicitly for this purpose.
What You Will Learn
- How the main thread works and JavaScript's run-to-completion model
- What are Long Tasks and how to identify them with Chrome DevTools
- How scheduler.yield() differs from setTimeout and Promise.resolve()
- Practical patterns to break up heavy tasks while preserving priority
- scheduler.postTask() for tasks with explicit priority
- How to measure INP improvement after optimizations
The Problem: Run-to-Completion and Long Tasks
Imagine having an event handler that, at the click of a button, must process an array of 10,000 elements and update the DOM. Without optimizations:
// PROBLEMA: questo handler blocca il main thread per centinaia di ms
document.getElementById('process-btn').addEventListener('click', () => {
const results = [];
// Elaborazione sincrona di 10.000 elementi
for (const item of largeDataset) {
results.push(expensiveTransform(item)); // ogni chiamata costa ~0.1ms
}
// Aggiornamento DOM con tutti i risultati
renderResults(results);
});
This handler runs synchronously for ~1000ms (10,000 items * 0.1ms). During that entire second, the browser cannot respond to other inputs, it cannot update the animation of a loading spinner, and cannot process other events. The INP for this interaction will be > 1000ms.
View the Problem with Performance Observer
// Monitora i Long Tasks in produzione
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long Task: ${Math.round(entry.duration)}ms`, {
startTime: entry.startTime,
attribution: entry.attribution,
});
}
}
});
observer.observe({ type: 'longtask', buffered: true });
scheduler.yield(): The Native Solution
scheduler.yield() and a new Browser Scheduler API (Baseline 2024, 87% support
browser) which allows a JavaScript task to relinquish control to the browser in a controlled manner.
The task can then resume where it stopped, with the assurance that the browser had
the possibility of processing pending events.
Here's how to rewrite the previous example with scheduler.yield():
document.getElementById('process-btn').addEventListener('click', async () => {
const results = [];
const CHUNK_SIZE = 100; // processa 100 elementi per volta
for (let i = 0; i < largeDataset.length; i += CHUNK_SIZE) {
// Processa un chunk di elementi
const chunk = largeDataset.slice(i, i + CHUNK_SIZE);
for (const item of chunk) {
results.push(expensiveTransform(item));
}
// Cedi il controllo al browser tra un chunk e l'altro
// Il browser puo ora processare input pendenti, animazioni, ecc.
await scheduler.yield();
}
// Aggiorna il DOM con i risultati finali
renderResults(results);
});
With this pattern, instead of a single Long Task of 1000ms, we have 100 tasks of 10ms each, with "yield points" between one and the other. The browser can respond to input between chunks, bringing the INP from >1000ms to <50ms for the initial response.
scheduler.yield() vs setTimeout(0) vs Promise.resolve()
The fundamental difference between scheduler.yield() and the previous techniques is
in the prioritization:
| Method | Priority | Behavior | Problem |
|---|---|---|---|
setTimeout(fn, 0) |
Low | Adds to the macrotask queue | Minimum 4ms delay, unpredictable priority |
Promise.resolve() |
High (microtasks) | Runs before other tasks | It doesn't really give up control to the browser |
scheduler.yield() |
Inherits from the calling task | Gives up control, maintains original priority | None (except 87% browser support) |
The key point of scheduler.yield() and which maintains the priority of the calling task.
If the user just clicked a button (high priority), subsequent chunks will still have it
high priority compared to other background tasks. With setTimeout(0), chunks
they would be demoted to low priority, possibly being overtaken by other tasks.
scheduler.postTask(): Full Priority Control
For more advanced scenarios, scheduler.postTask() allows you to schedule tasks with
explicit priority: 'user-blocking' (criticism, e.g. response to user input),
'user-visible' (important, e.g. content rendering), e 'background'
(not urgent, e.g. analytics, prefetch).
// Task critico: risposta diretta a un input utente
await scheduler.postTask(
() => {
updateUIImmediately();
},
{ priority: 'user-blocking' }
);
// Task visibile: aggiornamento interfaccia non urgente
await scheduler.postTask(
() => {
updateSecondaryContent();
},
{ priority: 'user-visible' }
);
// Task background: analytics, prefetch
await scheduler.postTask(
() => {
sendAnalyticsData();
prefetchNextPage();
},
{ priority: 'background' }
);
You can also delete pending tasks via TaskController:
const controller = new TaskController({ priority: 'user-visible' });
// Avvia il task
const taskPromise = scheduler.postTask(
() => { doWork(); },
{ signal: controller.signal }
);
// Cancella il task se l'utente naviga via
window.addEventListener('beforeunload', () => {
controller.abort();
});
Practical Patterns for JavaScript Frameworks
React: Heavy Rendering Splitting
function HeavyList({ items }: { items: Item[] }) {
const [visibleItems, setVisibleItems] = useState<Item[]>([]);
useEffect(() => {
const renderChunks = async () => {
const CHUNK_SIZE = 50;
const allItems: Item[] = [];
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
allItems.push(...chunk);
// Aggiorna lo stato con il chunk corrente
setVisibleItems([...allItems]);
// Cedi il controllo prima del prossimo chunk
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
} else {
// Fallback per browser non supportati
await new Promise(resolve => setTimeout(resolve, 0));
}
}
};
renderChunks();
}, [items]);
return <ul>{visibleItems.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
Angular: Heavy Computation in OnInit
@Component({
selector: 'app-data-table',
template: `<div *ngFor="let row of processedRows">{{ row.value }}</div>`
})
export class DataTableComponent implements OnInit {
processedRows: ProcessedRow[] = [];
constructor(private ngZone: NgZone) {}
async ngOnInit() {
// Esegui fuori da Angular zone per evitare change detection
// ad ogni yield
await this.ngZone.runOutsideAngular(async () => {
const CHUNK_SIZE = 100;
const results: ProcessedRow[] = [];
for (let i = 0; i < this.rawData.length; i += CHUNK_SIZE) {
const chunk = this.rawData.slice(i, i + CHUNK_SIZE);
results.push(...chunk.map(r => this.processRow(r)));
if ('scheduler' in window) {
await (window as any).scheduler.yield();
}
}
// Rientra in Angular zone solo per l'aggiornamento finale
this.ngZone.run(() => {
this.processedRows = results;
});
});
}
}
How to Verify Improvement
After implementing scheduler.yield(), check the INP improvement:
- Chrome DevTools Performance Panel: record the same interaction before and after optimization. You should see many short tasks instead of one long one.
- web-vitals.js in production: monitor INP P75 for a few days afterward the deployment. A typical improvement is 40% to 70% of the INP value.
- Interaction to Next Paint trace: in the Performance panel, the input delay should be significantly reduced, as Long Tasks no longer block the main thread at click time.
Browser and Fallback support
scheduler.yield() and supported by Chrome 115+, Edge 115+, Safari 17+ (87% of users).
For Firefox and older browsers, use fallback new Promise(resolve => setTimeout(resolve, 0)).
Create a utility function that abstracts the difference:
async function yieldToMain(): Promise<void> {
if ('scheduler' in window && 'yield' in (window as any).scheduler) {
return (window as any).scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
Conclusions
scheduler.yield() It is the most powerful tool available today for improving INP
on complex JavaScript interfaces. The technique of breaking heavy tasks into chunks with yield points
intermediate and conceptually simple but has a huge impact on the perceived user experience.
The key is to first identify problematic interactions with Chrome DevTools, then apply the chunking+yield pattern only where necessary. Over-engineering with yield everywhere can introduce unnecessary overhead; apply it to tasks that you know are heavy (processing large datasets, rendering of many DOM elements, expensive computations).







