⚡ Prestazioni web

Web worker: spostare i calcoli pesanti fuori dal main thread

Quando conviene usare un web worker, come comunicare con postMessage e oggetti trasferibili, e quali lavori spostare per mantenere la pagina reattiva.

Il browser esegue JavaScript, calcolo degli stili, layout e disegno su un unico thread principale. Finché il codice è leggero non c'è problema, ma quando una pagina deve elaborare un file CSV di migliaia di righe, filtrare un grande elenco a ogni tasto premuto, comprimere un'immagine o eseguire un algoritmo di ricerca, il main thread si blocca e l'interfaccia smette di rispondere.

Spezzare il lavoro in piccoli blocchi aiuta, ma non riduce la quantità totale di calcolo. I web worker offrono una via diversa: eseguono JavaScript in un thread separato, lasciando il main thread libero di rispondere all'utente.

Cosa può fare un worker e cosa no

Un worker è un ambiente JavaScript isolato. Ha a disposizione molte API utili, ma non tutte:

Disponibile nel worker Non disponibile nel worker
fetch, WebSocket Accesso al DOM
IndexedDB, Cache Storage window e document
Timer, Promise, moduli ES localStorage
Crittografia (Web Crypto) Manipolazione diretta della pagina
OffscreenCanvas Eventi dell'interfaccia

La regola pratica: il worker calcola, il main thread mostra. Tutto ciò che riguarda l'aspetto della pagina resta sul main thread; tutto ciò che è puro elaborare dati può spostarsi.

Un esempio minimo

Creare un worker richiede un file separato. Con i moduli ES la sintassi è moderna e pulita:

// main.js
const worker = new Worker(new URL('./filtro.worker.js', import.meta.url),
                          { type: 'module' });

campo.addEventListener('input', () => {
  worker.postMessage({ testo: campo.value });
});

worker.addEventListener('message', (e) => {
  mostraRisultati(e.data.risultati);
});
// filtro.worker.js
let catalogo = [];
fetch('/dati/catalogo.json').then(r => r.json()).then(d => { catalogo = d; });

self.addEventListener('message', (e) => {
  const q = e.data.testo.toLowerCase();
  const risultati = catalogo.filter(p => p.nome.toLowerCase().includes(q))
                            .slice(0, 50);
  self.postMessage({ risultati });
});

L'utente digita, il main thread invia il testo e resta libero; il worker filtra e restituisce solo i primi risultati da disegnare. Anche se il catalogo è grande, la digitazione non si blocca.

Il costo della comunicazione

I messaggi tra main thread e worker vengono copiati con l'algoritmo di clonazione strutturata. Per oggetti piccoli il costo è trascurabile, ma copiare grandi strutture a ogni messaggio può annullare il vantaggio. Ci sono tre strategie per limitarlo:

  1. tenere i dati nel worker: come nell'esempio, il catalogo si carica una volta dentro il worker e viaggiano solo la ricerca e un risultato ridotto;
  2. oggetti trasferibili: un ArrayBuffer può essere trasferito invece che copiato, passando la proprietà al worker in tempo quasi nullo. Dopo il trasferimento non è più utilizzabile dal mittente;
  3. inviare meno: restituire solo ciò che serve a disegnare, non l'intero insieme elaborato.
const buffer = await file.arrayBuffer();
worker.postMessage({ buffer }, [buffer]); // trasferito, non copiato

Lavori adatti a un worker

Nel mio lavoro i casi in cui un worker fa davvero la differenza sono sempre di questo tipo:

  • analisi di file caricati dall'utente: CSV, JSON di grandi dimensioni, log;
  • ricerca e ordinamento su elenchi lunghi lato client;
  • elaborazione di immagini: ridimensionamento, filtri, conversione di formato tramite OffscreenCanvas;
  • compressione e decompressione di dati;
  • evidenziazione della sintassi o formattazione di testi lunghi;
  • calcoli numerici: statistiche, simulazioni, grafici con molti punti.

Non conviene invece spostare in un worker operazioni brevi: creare il worker e scambiare messaggi ha un costo, e per lavori di pochi millisecondi il guadagno è nullo.

Rendere la comunicazione più comoda

Gestire messaggi ed eventi a mano diventa scomodo quando le funzioni sono molte. Librerie come Comlink trasformano il worker in un oggetto con metodi asincroni: dal main thread si chiama await api.filtra(testo) come se fosse una funzione locale, e la libreria gestisce i messaggi. È una comodità, non un requisito: per uno o due tipi di messaggio il codice manuale resta chiaro.

Errori comuni

  • Dimenticare la gestione degli errori: un'eccezione nel worker non appare nel flusso del main thread. Ascolta l'evento error e, nei messaggi, restituisci uno stato esplicito.
  • Creare un worker per ogni operazione: crealo una volta e riutilizzalo.
  • Risposte fuori ordine: se l'utente digita velocemente, una risposta vecchia può arrivare dopo una nuova. Associa a ogni richiesta un identificativo e ignora le risposte superate.
  • Aspettarsi accesso al DOM: se il codice tocca document, va diviso in una parte di calcolo e una di presentazione.

Verificare il beneficio

Nel pannello Performance di DevTools i worker compaiono come tracce separate. Prima della modifica vedrai task lunghi sul main thread durante l'operazione; dopo, il lavoro si sposta sulla traccia del worker e il main thread mostra solo brevi task di ricezione e disegno. Ripeti la misura con la CPU rallentata, per simulare un dispositivo di fascia bassa.

In sintesi

  • Un web worker esegue JavaScript in un thread separato e libera il main thread.
  • Il worker elabora dati; il DOM e l'interfaccia restano sul main thread.
  • Tieni i dati grandi nel worker e usa gli oggetti trasferibili per i buffer.
  • È utile per analisi di file, ricerche, immagini e calcoli, non per operazioni brevi.
  • Gestisci errori e risposte fuori ordine, e verifica il risultato nel profilo.
MM
Marco Marzaduri

Marco Marzaduri si occupa di cybersecurity, informatica forense e sviluppo web. Laureato in informatica, unisce il rigore dell'analisi tecnica all'attenzione per le persone che usano la tecnologia.

Leggi anche