back to top

Web Workers JavaScript: Spostare Lavoro Off-Thread per la Performance

Le moderne applicazioni web sono sempre più ricche di funzionalità, interattive e, di conseguenza, computazionalmente impegnative. Elaborazione di grandi quantità di dati, manipolazione complessa di immagini, simulazioni, o calcoli intensivi possono rapidamente mettere sotto pressione il browser. Quando il codice JavaScript esegue operazioni lunghe e bloccanti sul thread principale (il main thread), l’interfaccia utente smette di rispondere: animazioni si bloccano, scroll diventa scattoso, e l’utente percepisce l’applicazione come lenta o addirittura “freezata”. Questo impatta negativamente sulla user experience e sui Core Web Vitals, in particolare su metriche come l’INP (Interaction to Next Paint).

Fortunatamente, non tutto il codice JavaScript deve essere eseguito sul thread principale. Il browser mette a disposizione una potente API chiamata Web Workers, che permette di spostare l’esecuzione di script in thread separati. Questo libera il main thread, mantenendo l’interfaccia utente fluida e reattiva anche durante l’esecuzione di compiti onerosi. In questo articolo approfondiremo cosa sono i Web Workers, quando e perché utilizzarli, vedremo un esempio pratico di implementazione e analizzeremo best practice e limitazioni.

Cosa Sono i Web Workers?

I Web Workers sono script JavaScript che vengono eseguiti in un thread in background, separato dal thread principale di esecuzione del browser. Questo thread separato non ha accesso diretto al DOM, né a oggetti globali come window o document. La comunicazione tra il thread principale e il thread del worker avviene esclusivamente tramite un sistema di messaggi.

Immagina il main thread come un cameriere super indaffarato che deve contemporaneamente prendere ordini, portare i piatti, pulire i tavoli e rispondere al telefono. Se gli dai anche il compito di sbucciare 10 kg di patate, tutto il resto si ferma. Un Web Worker è come un cuoco separato in cucina: prende l’ordine (il compito), sbuccia le patate (esegue il compito pesante) e quando ha finito, passa il risultato al cameriere (il main thread) che può continuare a servire i clienti (interagire con l’utente) senza interruzioni.

Ci sono diversi tipi di worker, ma il più comune e quello su cui ci concentreremo in questo articolo è il Dedicated Worker (Worker Dedicato). Un Dedicated Worker è associato a un singolo script che lo ha creato. Esistono anche gli Shared Workers (condivisibili tra più contesti di navigazione con la stessa origine) e i Service Workers (utilizzati principalmente per caching e funzionalità offline nelle PWA), ma il concetto fondamentale di esecuzione off-thread rimane lo stesso.

La separazione tra main thread e worker è fondamentale per la stabilità e la performance. Eseguendo codice intensivo in un worker, si evita che crash o blocchi nel codice del worker influenzino direttamente l’interfaccia utente o la reattività della pagina.

Quando e Perché Usare i Web Workers?

Utilizzare i Web Workers è una strategia di ottimizzazione delle performance eccellente ogni volta che ti trovi a dover eseguire codice JavaScript che richiede un tempo di elaborazione significativo e che, se eseguito sul thread principale, potrebbe causare blocchi o rallentamenti dell’UI.

Ecco alcuni scenari tipici in cui l’uso dei Web Workers è particolarmente vantaggioso:

  1. Elaborazione di Dati Pesanti: Analisi, trasformazione o ordinamento di grandi array o strutture dati.
  2. Calcoli Computazionali Intensi: Operazioni matematiche complesse, simulazioni fisiche, rendering di grafica 3D lato client, calcoli crittografici.
  3. Elaborazione di Immagini/Video: Filtri, resizing, compressione o analisi di contenuti multimediali direttamente nel browser.
  4. Sincronizzazione in Background: Salvataggio periodico di dati o sincronizzazione con un server senza bloccare l’interfaccia utente.
  5. Spell Checking o Analisi Testuale Complessa: Elaborazione di grandi blocchi di testo.
  6. Pre-fetching o Pre-elaborazione di Risorse: Caricare e preparare dati o risorse che saranno necessarie a breve.
  7. Lavori I/O intensivi (tramite Fetch/XMLHttpRequest): Anche se Workspace e XMLHttpRequest sono intrinsecamente asincroni, gestire le risposte e processare dati voluminosi può comunque essere impegnativo. Spostare anche la logica post-ricezione nel worker può essere utile. Consulta la nostra guida sulla gestione degli errori fetch per approfondire l’aspetto delle richieste.

Il motivo principale per cui dovresti considerare i Web Workers è mantenere un alto livello di reattività dell’interfaccia utente. Un’applicazione web che risponde istantaneamente alle interazioni dell’utente, anche mentre esegue compiti complessi in background, offre un’esperienza utente notevolmente superiore. Questo si traduce in metriche di performance migliori, come un Largest Contentful Paint (LCP) più stabile e un INP basso.

Ricorda che JavaScript, per sua natura, è un linguaggio single-threaded sul main thread del browser (scopri perché JavaScript è asincrono e come funziona l’Event Loop). I Web Workers sono lo strumento standard per superare questa limitazione per quanto riguarda i calcoli computazionali.

Esempio Pratico: Calcolo dei Numeri Primi

Vediamo un esempio concreto di come implementare un Web Worker per eseguire un calcolo potenzialmente lungo: trovare tutti i numeri primi fino a un dato limite.

Creeremo tre file:

  1. index.html: La pagina HTML con l’interfaccia utente.
  2. main.js: Lo script eseguito sul thread principale che gestisce l’UI e crea il worker.
  3. worker.js: Lo script eseguito nel thread separato che esegue il calcolo.

index.html

<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Esempio Web Worker</title>
    <style>
        body { font-family: sans-serif; }
        #loading { color: blue; display: none; }
        #result { margin-top: 20px; }
    </style>
</head>
<body>
    <h1>Test Web Worker per Calcoli Pesanti</h1>

    <p>Trova i numeri primi fino al limite specificato.</p>

    <div>
        <label for="limit">Limite Superiore:</label>
        <input type="number" id="limit" value="100000">
        <button id="startCalculation">Avvia Calcolo (Worker)</button>
        <button id="startCalculationMain">Avvia Calcolo (Main Thread)</button>
    </div>

    <p id="loading">Calcolo in corso...</p>

    <div id="result">
        <h2>Risultato:</h2>
        <p>Numeri primi trovati: <span id="primeCount">-</span></p>
        <p>Tempo impiegato: <span id="timeTaken">-</span> ms</p>
        <div id="primeList" style="max-height: 200px; overflow-y: auto; border: 1px solid #ccc; padding: 10px;"></div>
    </div>

    <script src="main.js"></script>
</body>
</html>

Abbiamo aggiunto due pulsanti: uno che userà il Web Worker e uno che eseguirà lo stesso calcolo sul thread principale, giusto per confrontare l’impatto sulla reattività dell’UI.

worker.js

Questo script contiene la logica del calcolo.

// worker.js

/**
 * Funzione per trovare i numeri primi fino a un dato limite.
 * Algoritmo di Sieve of Eratosthenes semplificato.
 * @param {number} limit Il limite superiore (escluso).
 * @returns {number[]} Array di numeri primi.
 */
function findPrimes(limit) {
    const primes = [];
    // Per limiti molto grandi, questo array può diventare enorme.
    // Per scopi dimostrativi, generiamo solo un conteggio e i primi N per evitare blocchi UI post-worker.
    const tempPrimes = [];
    const isPrime = new Array(limit + 1).fill(true);
    isPrime[0] = isPrime[1] = false;

    for (let p = 2; p * p <= limit; p++) {
        if (isPrime[p]) {
            for (let i = p * p; i <= limit; i += p)
                isPrime[i] = false;
        }
    }

    let count = 0;
    for (let p = 2; p <= limit; p++) {
        if (isPrime[p]) {
            count++;
            if (tempPrimes.length < 100) { // Limita l'output per non appesantire la UI
                 tempPrimes.push(p);
            }
        }
    }

    // Invia il risultato al main thread.
    // self è l'oggetto globale nel contesto del worker.
    // postMessage può inviare dati che possono essere clonati (Structured Clone Algorithm).
    self.postMessage({
        primeCount: count,
        firstPrimes: tempPrimes,
        limit: limit // Utile per verificare a quale limite si riferisce il risultato
    });
}

// Il worker ascolta i messaggi dal main thread
self.onmessage = function(event) {
    // event.data contiene i dati inviati dal main thread.
    console.log('Worker ha ricevuto un messaggio:', event.data);
    const limit = event.data.limit;

    if (typeof limit !== 'number' || limit <= 1) {
        console.error('Worker ha ricevuto un limite non valido.');
        // È buona norma gestire errori e potenzialmente inviare un messaggio di errore al main thread
        self.postMessage({ error: 'Limite di calcolo non valido.' });
        return;
    }

    console.time(`Calcolo numeri primi fino a ${limit} nel worker`);
    findPrimes(limit);
    console.timeEnd(`Calcolo numeri primi fino a ${limit} nel worker`);
};

console.log('Worker script caricato.');

// APIs disponibili in un worker:
// self, navigator, location, XMLHttpRequest, fetch, setTimeout, setInterval, importScripts(), etc.
// APIs NON disponibili:
// window, document, parent

Nel file worker.js, la funzione findPrimes esegue il calcolo intensivo. Il punto cruciale è la riga self.onmessage. Questa è la funzione che viene eseguita quando il main thread invia un messaggio al worker. Il messaggio ricevuto è disponibile in event.data. Una volta completato il calcolo, self.postMessage(result) invia il risultato indietro al main thread. Nota l’uso di self che si riferisce al contesto globale del worker (simile a window nel main thread, ma senza le API del DOM).

main.js

Questo script gestisce l’interfaccia utente e l’interazione con il worker.

// main.js

const limitInput = document.getElementById('limit');
const startWorkerButton = document.getElementById('startCalculation');
const startMainButton = document.getElementById('startCalculationMain');
const loadingIndicator = document.getElementById('loading');
const primeCountSpan = document.getElementById('primeCount');
const timeTakenSpan = document.getElementById('timeTaken');
const primeListDiv = document.getElementById('primeList');

// Controlla se il browser supporta i Web Workers
if (window.Worker) {
    // Crea una nuova istanza del Web Worker
    // Il percorso è relativo alla directory dello script che crea il worker (main.js in questo caso)
    const myWorker = new Worker('worker.js');

    // Gestisce i messaggi in arrivo dal worker
    myWorker.onmessage = function(event) {
        console.log('Messaggio ricevuto dal worker:', event.data);

        // Nasconde l'indicatore di caricamento
        loadingIndicator.style.display = 'none';

        const result = event.data;

        if (result.error) {
            primeCountSpan.textContent = 'Errore';
            timeTakenSpan.textContent = 'N/A';
            primeListDiv.textContent = `Errore: ${result.error}`;
            return;
        }

        const endTime = performance.now();
        const startTime = parseFloat(sessionStorage.getItem('workerStartTime')); // Recupera il tempo di inizio
        const timeTaken = startTime ? (endTime - startTime).toFixed(2) : '-';

        // Aggiorna l'interfaccia utente con i risultati
        primeCountSpan.textContent = result.primeCount;
        timeTakenSpan.textContent = timeTaken;
        primeListDiv.textContent = `Primi 100 numeri primi trovati (su ${result.primeCount}): ${result.firstPrimes.join(', ')}${result.primeCount > result.firstPrimes.length ? ', ...' : ''}`;

        // Nota: Se il worker non fosse più necessario, potresti terminarlo:
        // myWorker.terminate();
        // Dopodiché, per usarlo di nuovo, dovresti creare una nuova istanza.
    };

    // Gestisce gli errori del worker
    myWorker.onerror = function(error) {
        console.error('Errore nel worker:', error);
        loadingIndicator.style.display = 'none';
        primeCountSpan.textContent = 'Errore';
        timeTakenSpan.textContent = 'N/A';
        primeListDiv.textContent = `Si è verificato un errore nel worker: ${error.message}`;
    };

    // Event listener per il pulsante "Avvia Calcolo (Worker)"
    startWorkerButton.onclick = function() {
        const limit = parseInt(limitInput.value, 10);
        if (isNaN(limit) || limit <= 1) {
            alert('Inserisci un limite valido (> 1).');
            return;
        }

        // Resetta i risultati e mostra l'indicatore di caricamento
        primeCountSpan.textContent = '-';
        timeTakenSpan.textContent = '-';
        primeListDiv.textContent = '';
        loadingIndicator.style.display = 'block';

        // Invia i dati (il limite) al worker
        console.log(`Invio messaggio al worker con limite: ${limit}`);
        sessionStorage.setItem('workerStartTime', performance.now()); // Salva il tempo di inizio
        myWorker.postMessage({ limit: limit }); // Invio un oggetto come messaggio
    };

} else {
    // Messaggio di fallback se i Web Workers non sono supportati
    console.warn('Il tuo browser non supporta i Web Workers.');
    startWorkerButton.disabled = true;
    startWorkerButton.textContent = 'Web Workers non supportati';
}

// --- Implementazione sul Main Thread per confronto ---
function findPrimesMainThread(limit) {
    const primes = [];
    const isPrime = new Array(limit + 1).fill(true);
    isPrime[0] = isPrime[1] = false;

    for (let p = 2; p * p <= limit; p++) {
        if (isPrime[p]) {
            for (let i = p * p; i <= limit; i += p)
                isPrime[i] = false;
        }
    }

    let count = 0;
    const tempPrimes = [];
    for (let p = 2; p <= limit; p++) {
        if (isPrime[p]) {
            count++;
             if (tempPrimes.length < 100) {
                 tempPrimes.push(p);
            }
        }
    }

    return {
        primeCount: count,
        firstPrimes: tempPrimes
    };
}

startMainButton.onclick = function() {
    const limit = parseInt(limitInput.value, 10);
    if (isNaN(limit) || limit <= 1) {
        alert('Inserisci un limite valido (> 1).');
        return;
    }

    primeCountSpan.textContent = '-';
    timeTakenSpan.textContent = '-';
    primeListDiv.textContent = '';
    loadingIndicator.style.display = 'block';

    const startTime = performance.now();
    console.time(`Calcolo numeri primi fino a ${limit} nel main thread`);

    // Esegue il calcolo direttamente sul main thread
    const result = findPrimesMainThread(limit);

    const endTime = performance.now();
    const timeTaken = (endTime - startTime).toFixed(2);

    console.timeEnd(`Calcolo numeri primi fino a ${limit} nel main thread`);

    // Aggiorna l'interfaccia utente con i risultati
    primeCountSpan.textContent = result.primeCount;
    timeTakenSpan.textContent = timeTaken;
     primeListDiv.textContent = `Primi 100 numeri primi trovati (su ${result.primeCount}): ${result.firstPrimes.join(', ')}${result.primeCount > result.firstPrimes.length ? ', ...' : ''}`;


    loadingIndicator.style.display = 'none';

    // PROVA a interagire con l'UI mentre il calcolo è in corso sul main thread.
    // Noterai che l'UI si blocca completamente!
};

console.log('Main script caricato.');

In main.js, creiamo l’istanza del worker con new Worker('worker.js'). L’evento onmessage del worker viene gestito per ricevere i risultati quando il worker li invia. L’evento onerror è fondamentale per gestire eventuali errori che si verificano all’interno del thread del worker. Quando l’utente clicca sul pulsante “Avvia Calcolo (Worker)”, prendiamo il limite dall’input e lo inviamo al worker usando myWorker.postMessage({ limit: limit }).

Provando questo esempio con un limite elevato (es. 1.000.000 o più), noterai una differenza drastica:

  • Cliccando “Avvia Calcolo (Main Thread)”, l’intera pagina si bloccherà per tutta la durata del calcolo. Non potrai scrollare, cliccare su altri elementi, o vedere animazioni.
  • Cliccando “Avvia Calcolo (Worker)”, l’indicatore “Calcolo in corso…” apparirà, ma potrai continuare a interagire liberamente con la pagina (scrollare, selezionare testo, ecc.). L’UI rimarrà reattiva perché il calcolo pesante viene eseguito in un thread separato dai Web Workers.

Questo dimostra concretamente il potere dei Web Workers nel migliorare la reattività dell’interfaccia utente.

Best Practice e Limitazioni nell’Uso dei Web Workers

Come ogni tecnologia, i Web Workers hanno le loro peculiarità e limitazioni che è importante conoscere per usarli in modo efficace.

Best Practice:

  1. Usali solo per compiti veramente pesanti: L’overhead di creazione di un worker e la comunicazione tramite messaggi introducono un costo. Non usarli per operazioni banali che richiedono solo pochi millisecondi. Il loro valore emerge per task che richiedono centinaia o migliaia di millisecondi.
  2. Mantieni il codice del worker focalizzato: Lo script del worker dovrebbe contenere solo la logica necessaria per il compito che deve eseguire. Evita di includere codice che non è strettamente legato al lavoro off-thread.
  3. Gestisci la comunicazione in modo efficiente: La comunicazione tramite postMessage clona i dati utilizzando l’Structured Clone Algorithm. Per oggetti o array molto grandi, la clonazione può richiedere tempo. Considera l’uso di Transferable Objects (come ArrayBuffer, MessagePort, ImageBitmap) che permettono di trasferire la proprietà dei dati al worker (o viceversa) invece di clonarli. Dopo il trasferimento, l’oggetto originale nel thread di origine non è più utilizzabile.
  4. Implementa una gestione degli errori robusta: Utilizza l’evento onerror nel main thread per catturare gli errori che si verificano nel worker. Questo ti permette di informare l’utente o gestire il problema senza che l’intera applicazione fallisca.
  5. Termina i worker quando non sono più necessari: Se un worker ha completato il suo compito e non è previsto che venga riutilizzato a breve, puoi terminarlo con worker.terminate(). Questo rilascia le risorse di sistema associate a quel thread. Per compiti ricorrenti, potrebbe essere più efficiente mantenere il worker attivo e inviargli nuovi messaggi.
  6. Carica script esterni con importScripts(): All’interno di un worker, non hai accesso ai tag <script> del documento principale. Puoi caricare script JavaScript esterni (ad esempio, librerie Helper) usando la funzione sincrona importScripts(). Ad esempio: importScripts('helper.js', 'altro-script.js');.
  7. Attenzione alle Cross-Origin Restrictions: Per motivi di sicurezza, lo script del worker deve rispettare la same-origin policy. Se il tuo script main.js è servito da https://example.com, lo script worker.js dovrà essere servito dalla stessa origine.

Limitazioni:

  1. Nessun accesso al DOM: Questa è la limitazione più significativa. I Web Workers non possono manipolare direttamente l’interfaccia utente o accedere agli oggetti window, document, parent. Tutta l’interazione UI deve avvenire tramite messaggi scambiati con il main thread.
  2. Comunicazione tramite messaggi: Sebbene potente, il modello di comunicazione asincrona tramite messaggi aggiunge una complessità. Non puoi semplicemente chiamare una funzione nel worker e ottenere un valore di ritorno immediato come faresti con una chiamata sincrona. Devi inviare un messaggio e attendere la risposta tramite l’evento onmessage.
  3. Costo di avvio: La creazione di un nuovo thread worker ha un costo iniziale. Per compiti molto brevi, il tempo necessario per avviare il worker e scambiare messaggi potrebbe superare il tempo di esecuzione del compito stesso sul main thread.
  4. Debugging: Il debugging dei Web Workers è supportato dagli strumenti di sviluppo della maggior parte dei browser moderni, ma può richiedere passaggi leggermente diversi rispetto al debugging del codice sul main thread.
  5. Supporto del browser: Sebbene ampiamente supportati, è sempre buona norma verificare il supporto browser (come mostrato nell’esempio con if (window.Worker)), soprattutto se si supportano browser legacy.

Considerando queste best practice e limitazioni, i Web Workers diventano uno strumento potente per migliorare le performance delle applicazioni web, specialmente in scenari dove calcoli intensivi rischiano di compromettere l’esperienza utente.

Domande Frequenti (FAQ) sui Web Workers

Ecco alcune domande comuni relative ai Web Workers:

  • I Web Workers possono accedere al DOM? No, i Web Workers vengono eseguiti in un thread separato e non hanno accesso diretto all’oggetto document o ad altri elementi del DOM. Tutte le modifiche all’interfaccia utente devono essere gestite dal main thread in base ai messaggi ricevuti dal worker.
  • Come comunicano il main thread e un Web Worker? La comunicazione avviene tramite il sistema di messaggi asincrono, usando i metodi postMessage() per inviare dati e gestendo gli eventi onmessage per ricevere dati. I dati vengono passati per copia ( Structured Clone Algorithm) o per trasferimento ( Transferable Objects).
  • Posso usare librerie JavaScript di terze parti all’interno di un Web Worker? Sì, puoi caricare script esterni o librerie all’interno di un worker usando la funzione importScripts(). Ad esempio, importScripts('my-library.js');.
  • Qual è la differenza tra Dedicated Worker e Shared Worker? Un Dedicated Worker è associato a un singolo script o finestra che lo ha creato. Uno Shared Worker può essere condiviso da più contesti di navigazione (finestre, tab, iframes) della stessa origine, comunicando tramite un oggetto MessagePort. I Service Workers sono un tipo speciale di worker per la gestione di eventi di rete, caching e push notifications, utilizzati principalmente nelle Progressive Web Apps (PWA).
  • I Web Workers sono adatti per effettuare chiamate API (fetch/XMLHttpRequest)? Sì, i Web Workers hanno accesso alle API Workspace e XMLHttpRequest, il che li rende adatti per eseguire richieste di rete e processare le risposte in background, senza bloccare il main thread.

Conclusione

I Web Workers sono uno strumento essenziale nell’arsenale di ogni sviluppatore frontend che punta a creare applicazioni web performanti e reattive. Affrontando le limitazioni del modello single-threaded di JavaScript sul main thread, permettono di spostare carichi di lavoro computazionali intensivi in thread separati, garantendo un’esperienza utente fluida anche durante operazioni complesse.

Abbiamo visto come definire, creare e comunicare con un Web Worker, esplorando un esempio pratico che ne dimostra chiaramente i benefici. Conoscere le best practice e le limitazioni è fondamentale per utilizzare questa API in modo efficace.

Integrare i Web Workers nella tua strategia di ottimizzazione può avere un impatto significativo sulle metriche di performance percepite e sui Core Web Vitals. Se stai cercando altri modi per migliorare la velocità e la reattività delle tue applicazioni, esplora le nostre guide su:

Inizia oggi stesso a sperimentare con i Web Workers e trasforma le tue applicazioni da “bloccate” a brillantemente reattive!

Condividi

Articoli Recenti

Categorie popolari