scheduler.yield() e Long Tasks: Sblocca il Main Thread
Il browser ha un solo main thread. Su questo thread girano, in sequenza, tutto il JavaScript della tua applicazione, il layout e il paint della pagina, e la risposta agli input dell'utente. Il modello di esecuzione di JavaScript e run-to-completion: un task JavaScript, una volta iniziato, viene eseguito fino alla fine senza interruzioni. Se quel task richiede 300ms, per quei 300ms il browser non puo fare nient'altro: non risponde ai click, non aggiorna l'interfaccia, non processa altri eventi.
Questo e il problema fondamentale che causa INP elevato. La soluzione tradizionale era usare
setTimeout(fn, 0) o Promise.resolve().then() per "dividere" il lavoro,
ma questi approcci hanno problemi di prioritizzazione e non comunicano al browser l'intenzione
di cedere il controllo. scheduler.yield() e la soluzione nativa
progettata esplicitamente per questo scopo.
Cosa Imparerai
- Come funziona il main thread e il modello run-to-completion di JavaScript
- Cosa sono i Long Tasks e come identificarli con Chrome DevTools
- Come scheduler.yield() differisce da setTimeout e Promise.resolve()
- Pattern pratici per spezzare task pesanti preservando la priorita
- scheduler.postTask() per task con priorita esplicita
- Come misurare il miglioramento INP dopo le ottimizzazioni
Il Problema: Run-to-Completion e Long Tasks
Immagina di avere un event handler che, al click di un bottone, deve processare un array di 10.000 elementi e aggiornare il DOM. Senza ottimizzazioni:
// 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);
});
Questo handler esegue in modo sincrono per ~1000ms (10.000 item * 0.1ms). Durante quell'intero secondo, il browser non puo rispondere ad altri input, non puo aggiornare l'animazione di un loading spinner, e non puo processare altri eventi. L'INP per questa interazione sara > 1000ms.
Visualizzare il Problema con 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(): La Soluzione Nativa
scheduler.yield() e una nuova API del Scheduler browser (Baseline 2024, 87% supporto
browser) che permette a un task JavaScript di cedere il controllo al browser in modo controllato.
Il task puo poi riprendersi dove si era fermato, con la garanzia che il browser abbia avuto
la possibilita di processare eventi pendenti.
Ecco come riscrivere l'esempio precedente con 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);
});
Con questo pattern, invece di un unico Long Task da 1000ms, abbiamo 100 task da 10ms ciascuno, con "yield points" tra uno e l'altro. Il browser puo rispondere agli input tra un chunk e l'altro, portando l'INP da > 1000ms a < 50ms per la risposta iniziale.
scheduler.yield() vs setTimeout(0) vs Promise.resolve()
La differenza fondamentale tra scheduler.yield() e le tecniche precedenti sta
nella prioritizzazione:
| Metodo | Priorita | Comportamento | Problema |
|---|---|---|---|
setTimeout(fn, 0) |
Bassa | Aggiunge alla coda dei macrotask | Minimo 4ms di delay, priorita imprevedibile |
Promise.resolve() |
Alta (microtask) | Esegue prima di altri task | Non cede realmente il controllo al browser |
scheduler.yield() |
Eredita dal task chiamante | Cede controllo, mantiene priorita originale | Nessuno (tranne il supporto browser del 87%) |
Il punto chiave di scheduler.yield() e che mantiene la priorita del task chiamante.
Se l'utente ha appena cliccato un bottone (alta priorita), i chunk successivi avranno ancora
alta priorita rispetto ad altri task di background. Con setTimeout(0), i chunk
verrebbero degradati a priorita bassa, potendo essere superati da altri task.
scheduler.postTask(): Controllo Completo della Priorita
Per scenari piu avanzati, scheduler.postTask() permette di pianificare task con
priorita esplicita: 'user-blocking' (critica, es. risposta a input utente),
'user-visible' (importante, es. rendering di contenuto), e 'background'
(non urgente, es. 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' }
);
Puoi anche cancellare task pendenti tramite 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();
});
Pattern Pratici per Framework JavaScript
React: Splitting del Rendering Pesante
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;
});
});
}
}
Come Verificare il Miglioramento
Dopo aver implementato scheduler.yield(), verifica il miglioramento INP:
- Chrome DevTools Performance Panel: registra la stessa interazione prima e dopo l'ottimizzazione. Dovresti vedere molti task brevi invece di uno lungo.
- web-vitals.js in produzione: monitora il P75 dell'INP per alcuni giorni dopo il deploy. Un miglioramento tipico e dal 40% al 70% del valore INP.
- Interaction to Next Paint trace: nel Performance panel, l'input delay dovrebbe ridursi significativamente, poiche i Long Tasks non bloccano piu il main thread al momento del click.
Supporto Browser e Fallback
scheduler.yield() e supportato da Chrome 115+, Edge 115+, Safari 17+ (87% degli utenti).
Per Firefox e browser piu vecchi, usa il fallback new Promise(resolve => setTimeout(resolve, 0)).
Crea un'utility function che astrae la differenza:
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));
}
Conclusioni
scheduler.yield() e lo strumento piu potente disponibile oggi per migliorare INP
su interfacce JavaScript complesse. La tecnica di spezzare i task pesanti in chunk con yield points
intermedi e concettualmente semplice ma ha un impatto enorme sull'esperienza utente percepita.
La chiave e identificare prima le interazioni problematiche con Chrome DevTools, poi applicare il pattern chunking+yield solo dove necessario. Over-engineering con yield ovunque puo introdurre overhead inutile; applicalo ai task che sai essere pesanti (elaborazione di grandi dataset, rendering di molti elementi DOM, computazioni costose).

Commenti
Caricamento commenti...
Accedi per commentare