planner.yield() en lange taken: ontgrendel de hoofdthread
De browser voert JavaScript op een bepaalde manier uit run-tot-voltooiing: wanneer start een JavaScript-taak, voltooit deze voordat u iets anders doet, inclusief het reageren op gebruikersinvoer, het uitvoeren van animaties of het updaten van de gebruikersinterface. Een taak die 300 ms duurt, houdt de browser 300 ms geblokkeerd. De gebruiker klikt op een knop, de klik wordt niet direct geregistreerd. Dit is de ingangsvertraging, en de hoofdoorzaak van hoge NPI.
De oplossing is niet om minder JavaScript te schrijven: het is om lange taken in meer stukken op te delen
klein, waardoor de controle over de browser tussen het ene deel en het andere wordt opgegeven. Dit is tot 2023
vereiste hack met setTimeout(fn, 0) o MessageChannel.
Met scheduler.yield() — vanaf 2024 beschikbaar in alle moderne browsers
— de controle overdragen aan de browser wordt een enkele regel code.
Wat je gaat leren
- Net als het run-to-completion-model blokkeert het de rode draad en bestraft het de INP
- Lange taken: identificeer ze met PerformanceObserver en Chrome DevTools
- planner.yield(): de nieuwe API om de controle over de browser op te geven
- Taaksplitsing: zware algoritmen doorbreken zonder radicale refactoring
- planner.postTask(): taakprioriteit (gebruikersblokkering, gebruiker zichtbaar, achtergrond)
- isInputPending(): Voer werk alleen uit als er geen invoer in behandeling is
- Praktische patronen voor de echte wereld: virtuele lijsten, batchberekeningen, werkthreads
Het probleem: Run-to-Completion en invoervertraging
De rode draad behandelt alles: JavaScript, lay-out, schilderen, invoergebeurtenissen. Wanneer één JavaScript-taak wordt uitgevoerd, alle andere taken (inclusief invoergebeurtenissen) ze wachten in de rij. Een taak van 500 ms levert 500 ms invoervertraging op: de gebruiker klikt, scroll, typ, maar de browser reageert pas als de taak is voltooid.
// Identificare i Long Tasks con PerformanceObserver
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
// Task oltre 50ms: "Long Task" per definizione
console.warn('Long Task detected:', {
duration: Math.round(entry.duration),
startTime: Math.round(entry.startTime),
// attribution: quale script/funzione e responsabile
attribution: entry.attribution?.map((attr) => ({
name: attr.name,
containerType: attr.containerType,
containerSrc: attr.containerSrc,
})),
});
}
}
});
// Osserva sia longtask che LoAF (Long Animation Frame)
observer.observe({ type: 'longtask', buffered: true });
observer.observe({ type: 'long-animation-frame', buffered: true });
// Calcola il Total Blocking Time (TBT) - metrica Lighthouse
let totalBlockingTime = 0;
const tbtObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Solo la parte eccedente i 50ms contribuisce al TBT
const blockingTime = entry.duration - 50;
if (blockingTime > 0) {
totalBlockingTime += blockingTime;
}
}
});
tbtObserver.observe({ type: 'longtask', buffered: true });
planner.yield(): Geef controle aan de browser
scheduler.yield() retourneert een belofte die later wordt opgelost
dat de browser de mogelijkheid had om invoer te verwerken en de gebruikersinterface bij te werken.
Het is de gemakkelijkste manier om een lange taak op te delen in afzonderlijke microtaken.
waardoor de browser kan "ademen" tussen het ene deel en het andere.
// PRIMA: task monolitico da 400ms che blocca il main thread
async function processLargeDatasetBlocking(items: DataItem[]): Promise {
// Questo loop gira per ~400ms senza mai cedere il controllo
for (const item of items) {
await expensiveTransformation(item); // 0.4ms per item, 1000 items = 400ms
}
updateUI(items);
}
// DOPO: stesso algoritmo con scheduler.yield() ogni N item
async function processLargeDatasetYielding(items: DataItem[]): Promise {
const CHUNK_SIZE = 50; // Processa 50 item per chunk (~20ms per chunk)
for (let i = 0; i < items.length; i++) {
await expensiveTransformation(items[i]);
// Cedi il controllo al browser ogni CHUNK_SIZE item
if (i % CHUNK_SIZE === 0) {
await scheduler.yield();
// Il browser ora puo:
// - processare input events (click, scroll, keyboard)
// - eseguire animazioni pendenti
// - aggiornare la UI
// Poi il nostro task riprende dalla prossima iterazione
}
}
updateUI(items);
}
// Utilita: yield con priorita specifica
async function yieldToMain(): Promise {
// Fallback per browser senza scheduler.yield()
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield();
} else {
// Fallback: setTimeout 0 + MessageChannel
await new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(undefined);
});
}
}
planner.postTask(): Taakprioriteit
scheduler.postTask() Hiermee kunt u taken met prioriteiten plannen
expliciet. Drie niveaus: user-blocking (directe reactie op de gebruiker,
hoogste prioriteit), user-visible (standaard, voor niet-kritieke weergave),
background (niet-dringende werkzaamheden, lage prioriteit).
// scheduler.postTask(): schedulazione con priorita
// Task ad alta priorita: risposta diretta all'input utente
async function handleSearchInput(query: string): Promise {
// user-blocking: processa immediatamente, prima degli altri task
await scheduler.postTask(
() => filterResults(query),
{ priority: 'user-blocking' }
);
}
// Task a priorita normale: aggiornamento UI visibile
async function renderSearchResults(results: SearchResult[]): Promise {
await scheduler.postTask(
() => renderToDOM(results),
{ priority: 'user-visible' }
);
}
// Task a bassa priorita: analytics, prefetch, cleanup
async function sendAnalytics(event: AnalyticsEvent): Promise {
await scheduler.postTask(
() => analyticsService.track(event),
{ priority: 'background' }
);
}
// Abort controller: cancella task se non piu necessario
// Esempio: l'utente digita velocemente, cancella la ricerca precedente
let searchController: AbortController | null = null;
async function handleInput(query: string): Promise {
// Cancella la ricerca precedente
searchController?.abort();
searchController = new AbortController();
try {
const results = await scheduler.postTask(
() => searchDatabase(query),
{
priority: 'user-blocking',
signal: searchController.signal,
}
);
renderResults(results);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Task cancellato: normale, non loggare come errore
return;
}
throw error;
}
}
isInputPending(): Adaptief rendement
navigator.scheduling.isInputPending() maakt het mogelijk om te controleren
als er invoergebeurtenissen in de wachtrij staan voordat de controle wordt opgegeven. Dit maakt het mogelijk
alleen op te leveren wanneer dat nodig is, waardoor de doorvoer van de taak wordt gemaximaliseerd.
// isInputPending(): yield solo quando ci sono input in attesa
async function processWithAdaptiveYield(items: DataItem[]): Promise {
const CHUNK_SIZE = 100;
let startTime = performance.now();
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Strategia 1: yield se ci sono input pending
if (
i % CHUNK_SIZE === 0 &&
navigator.scheduling?.isInputPending()
) {
await scheduler.yield();
startTime = performance.now();
continue;
}
// Strategia 2: yield se il chunk ha impiegato troppo (>16ms = 1 frame)
if (i % 10 === 0) {
const elapsed = performance.now() - startTime;
if (elapsed > 16) {
await scheduler.yield();
startTime = performance.now();
}
}
}
}
// Combinazione ottimale per produzione
async function processOptimal(items: DataItem[]): Promise {
const DEADLINE_MS = 5; // Yield ogni 5ms per mantenere frame rate fluido
let lastYield = performance.now();
for (const item of items) {
processItem(item);
const now = performance.now();
if (now - lastYield >= DEADLINE_MS || navigator.scheduling?.isInputPending()) {
await scheduler.yield();
lastYield = performance.now();
}
}
}
Webwerkers voor CPU-intensieve taken
Sommige taken kunnen niet simpelweg ‘verbroken’ worden: het zijn sequentiële algoritmen die geheel moet draaien. Hiervoor is de oplossing het werk te verplaatsen op een Webwerker, waarbij de hoofdthread vrij blijft voor invoer.
// worker.ts: algoritmo pesante fuori dal main thread
self.onmessage = async (event: MessageEvent) => {
const { items, taskId } = event.data;
try {
// Questo algoritmo puo durare anche 2 secondi
// ma non blocca il main thread
const result = await heavyComputation(items);
self.postMessage({ taskId, result, success: true });
} catch (error) {
self.postMessage({
taskId,
error: (error as Error).message,
success: false
});
}
};
async function heavyComputation(items: DataItem[]): Promise {
// Sorting, filtering, aggregation complessa
return items
.filter((item) => item.value > 0)
.sort((a, b) => b.score - a.score)
.reduce((acc, item) => {
acc[item.category] = (acc[item.category] ?? 0) + item.value;
return acc;
}, {} as ProcessedData);
}
// main.ts: usa il Worker senza bloccare il main thread
class ComputationWorkerPool {
private worker: Worker;
private pendingTasks = new Map void;
reject: (error: Error) => void;
}>();
constructor() {
this.worker = new Worker(new URL('./worker.ts', import.meta.url));
this.worker.onmessage = this.handleMessage.bind(this);
}
process(items: DataItem[]): Promise {
return new Promise((resolve, reject) => {
const taskId = crypto.randomUUID();
this.pendingTasks.set(taskId, { resolve, reject });
this.worker.postMessage({ items, taskId });
});
}
private handleMessage(event: MessageEvent): void {
const { taskId, result, error, success } = event.data;
const task = this.pendingTasks.get(taskId);
if (!task) return;
this.pendingTasks.delete(taskId);
if (success) {
task.resolve(result);
} else {
task.reject(new Error(error));
}
}
}
// Uso: il main thread rimane completamente libero
const pool = new ComputationWorkerPool();
const result = await pool.process(largeDataset);
// Durante la computazione nel worker, l'UI rimane perfettamente reattiva
Checklist: Hoofddraadoptimalisatie voor INP
- Identificeer lange taken met PerformanceObserver (type lange taak) of het tabblad Prestaties van Chrome DevTools
- Voor batchalgoritmen: verbreek elke 5-16 ms met planner.yield().
- Voor niet-deelbare sequentiële algoritmen: ga naar Web Worker
- Voor geplande taken: gebruik planner.postTask() met de juiste prioriteit
- Impact meten: Vergelijk INP P75 voor en na met CrUX of web-vitals.js
Conclusies
scheduler.yield() e scheduler.postTask() zij vertegenwoordigen
een fundamentele verandering in de manier waarop we performant JavaScript schrijven: van hacken met
setTimeout om API's semantisch te wissen met volledige browserondersteuning. Gecombineerd met
Met Web Workers voor CPU-intensieve taken kunt u de INP onder de 200 ms houden
zelfs met complexe JavaScript-applicaties.







