Diego Betto
Foto di Jackson Sophat su Unsplash

Diego Betto · 29 settembre 2026 · 12 min di lettura

WebGPU e AI nel browser: inferenza client-side con JavaScript e TypeScript

Come eseguire modelli di machine learning direttamente nel browser con WebGPU e Transformers.js: caricamento del modello, Web Worker, fallback WASM e limiti di memoria.

Condividi:XLinkedInFacebookWhatsApp

Fino a poco tempo fa “aggiungere l’AI a una web app” voleva dire una cosa sola: una chiamata HTTP verso un’API, con relativa latenza di rete, costo per token e dati dell’utente che lasciano il dispositivo. Per molti casi d’uso resta la scelta giusta. Ma per una fetta crescente di funzionalità — ricerca semantica su contenuti locali, classificazione di testo, OCR, rimozione dello sfondo da un’immagine, trascrizione — il modello può girare direttamente nel browser, sulla GPU dell’utente, tramite WebGPU.

Il risultato: zero latenza di rete dopo il primo caricamento, zero costi server per l’inferenza, e dati che non escono mai dal dispositivo. In cambio, devi gestire tu download del modello, memoria e compatibilità tra dispositivi molto diversi. Vediamo come, con codice TypeScript reale.

Perché WebGPU cambia le cose

WebGL permetteva già di usare la GPU dal browser, ma era un’API pensata per disegnare triangoli: fare calcolo generico significava travestire i dati da texture. WebGPU espone invece i compute shader come cittadini di prima classe, con buffer di memoria, pipeline di calcolo e un modello più vicino a Vulkan/Metal/Direct3D 12. È esattamente il tipo di primitiva di cui hanno bisogno le moltiplicazioni di matrici alla base di ogni rete neurale.

In pratica non scriverai quasi mai shader a mano: runtime come ONNX Runtime Web e librerie come Transformers.js (che lo usa sotto il cofano) traducono il grafo del modello in operazioni WebGPU per te. Quando WebGPU non c’è, ripiegano su WebAssembly sulla CPU — più lento, ma funzionante ovunque.

ℹ️ Supporto nel 2026

WebGPU è disponibile in Chrome ed Edge dal 2023, in Safari da Safari 26 e in Firefox (prima su Windows, poi sulle altre piattaforme). La copertura è ormai ampia ma non universale — soprattutto su Linux e su GPU mobile datate — quindi il feature detection con fallback resta obbligatorio, non opzionale.

Lo stack: cosa scegliere

Libreria Livello Quando usarla
@huggingface/transformers Alto (pipeline pronte) Embedding, classificazione, traduzione, OCR, speech-to-text con modelli Hugging Face
onnxruntime-web Medio (sessioni ONNX) Hai un tuo modello esportato in ONNX e vuoi controllo su input/output tensor
@mlc-ai/web-llm Alto (API stile OpenAI) LLM conversazionali (Llama, Qwen, Phi…) interamente nel browser

Per l’esempio di questo articolo usiamo Transformers.js: è il punto d’ingresso più rapido e copre il caso d’uso più utile per la maggior parte delle app, la ricerca semantica.

Il caso d’uso: ricerca semantica locale

Obiettivo: l’utente scrive “come annullo una richiesta fetch” e trova il documento intitolato “AbortController e segnali di cancellazione”, anche senza parole in comune. Serve un modello di embedding che trasformi ogni testo in un vettore numerico; testi con significato simile producono vettori vicini.

Useremo Xenova/all-MiniLM-L6-v2: piccolo (circa 23 MB quantizzato a 8 bit), veloce, vettori a 384 dimensioni. Per contenuti in italiano conviene un modello multilingua come Xenova/paraphrase-multilingual-MiniLM-L12-v2, un po’ più pesante.

npm install @huggingface/transformers
npm install -D @webgpu/types

@webgpu/types aggiunge le definizioni per navigator.gpu, che TypeScript non include ancora nella lib DOM di default:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["@webgpu/types"]
  }
}

Regola numero uno: l’inferenza va in un Web Worker

Caricare un modello significa scaricare decine di MB, decomprimerli, compilare shader. Anche con la GPU a fare il lavoro pesante, la preparazione dei tensori e la tokenizzazione girano in JavaScript. Farlo sul main thread significa bloccare l’interfaccia per centinaia di millisecondi — esattamente il problema che ho descritto nell’articolo sui Web Worker e che si traduce in un INP pessimo nei Core Web Vitals.

Buona notizia: WebGPU è disponibile anche dentro i Dedicated Worker (navigator.gpu esiste in WorkerNavigator), quindi tutto — download, compilazione, inferenza — può vivere fuori dal main thread.

Partiamo dal protocollo dei messaggi, tipizzato con una union discriminata così che main thread e worker non possano disallinearsi:

// embed.protocol.ts
export type WorkerRequest =
  | { type: "load" }
  | { type: "embed"; id: number; texts: string[] };

export type WorkerResponse =
  | { type: "progress"; file: string; progress: number }
  | { type: "ready"; device: "webgpu" | "wasm" }
  | { type: "result"; id: number; vectors: Float32Array; dims: number }
  | { type: "error"; id?: number; message: string };

Il worker

// embed.worker.ts
import { pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
import type { WorkerRequest, WorkerResponse } from "./embed.protocol";

const MODEL_ID = "Xenova/all-MiniLM-L6-v2";

let extractor: Promise<FeatureExtractionPipeline> | null = null;
let device: "webgpu" | "wasm" = "wasm";

const post = (msg: WorkerResponse, transfer: Transferable[] = []) =>
  self.postMessage(msg, { transfer });

async function detectDevice(): Promise<"webgpu" | "wasm"> {
  if (!("gpu" in navigator)) return "wasm";
  const adapter = await navigator.gpu.requestAdapter();
  return adapter ? "webgpu" : "wasm";
}

function getExtractor() {
  // Singleton: una sola istanza del modello, anche se arrivano più "load"
  extractor ??= (async () => {
    device = await detectDevice();
    const pipe = await pipeline("feature-extraction", MODEL_ID, {
      device,
      // q8 su WASM è molto più veloce; su GPU fp32 evita problemi di precisione
      dtype: device === "webgpu" ? "fp32" : "q8",
      progress_callback: (p) => {
        if (p.status === "progress") {
          post({ type: "progress", file: p.file, progress: p.progress });
        }
      },
    });
    return pipe;
  })();
  return extractor;
}

self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
  const msg = event.data;
  try {
    if (msg.type === "load") {
      await getExtractor();
      post({ type: "ready", device });
      return;
    }

    if (msg.type === "embed") {
      const pipe = await getExtractor();
      const output = await pipe(msg.texts, { pooling: "mean", normalize: true });
      const vectors = output.data as Float32Array;
      const dims = output.dims[1];
      // Trasferiamo il buffer invece di copiarlo: zero-copy tra thread
      post({ type: "result", id: msg.id, vectors, dims }, [vectors.buffer]);
    }
  } catch (err) {
    post({
      type: "error",
      id: msg.type === "embed" ? msg.id : undefined,
      message: err instanceof Error ? err.message : String(err),
    });
  }
};

Alcune scelte non ovvie:

  • pooling: "mean", normalize: true produce un singolo vettore per testo, già normalizzato a lunghezza 1. Questo rende la similarità del coseno uguale a un semplice prodotto scalare — più veloce da calcolare.
  • Il buffer viene trasferito, non copiato. Con centinaia di documenti da 384 float ciascuno, evitare la copia strutturata conta. Dopo il postMessage, vectors nel worker è inutilizzabile (detached): è voluto.
  • Il dtype dipende dal device. La quantizzazione a 8 bit riduce dimensione e tempi su CPU; sulla GPU la differenza di velocità è minore e fp32 è la scelta più robusta. Se l’adapter supporta la feature shader-f16, fp16 è un buon compromesso.

Il client sul main thread

// embedder.ts
import type { WorkerRequest, WorkerResponse } from "./embed.protocol";

export class Embedder {
  private worker = new Worker(new URL("./embed.worker.ts", import.meta.url), {
    type: "module",
  });
  private nextId = 0;
  private pending = new Map<
    number,
    { resolve: (v: Float32Array[]) => void; reject: (e: Error) => void }
  >();
  readonly ready: Promise<"webgpu" | "wasm">;

  constructor(onProgress?: (file: string, progress: number) => void) {
    this.ready = new Promise((resolve, reject) => {
      this.worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
        const msg = event.data;
        switch (msg.type) {
          case "progress":
            onProgress?.(msg.file, msg.progress);
            break;
          case "ready":
            resolve(msg.device);
            break;
          case "result": {
            const out: Float32Array[] = [];
            for (let i = 0; i < msg.vectors.length; i += msg.dims) {
              out.push(msg.vectors.subarray(i, i + msg.dims));
            }
            this.pending.get(msg.id)?.resolve(out);
            this.pending.delete(msg.id);
            break;
          }
          case "error": {
            const error = new Error(msg.message);
            if (msg.id === undefined) {
              reject(error);
            } else {
              this.pending.get(msg.id)?.reject(error);
              this.pending.delete(msg.id);
            }
          }
        }
      };
    });
    this.send({ type: "load" });
  }

  private send(msg: WorkerRequest) {
    this.worker.postMessage(msg);
  }

  async embed(texts: string[]): Promise<Float32Array[]> {
    await this.ready;
    const id = this.nextId++;
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.send({ type: "embed", id, texts });
    });
  }

  dispose() {
    this.worker.terminate();
  }
}

new URL("./embed.worker.ts", import.meta.url) è il pattern che Vite, webpack 5 e gli altri bundler moderni riconoscono per creare un chunk separato per il worker — nessuna configurazione extra.

La ricerca vera e propria

// search.ts
import { Embedder } from "./embedder";

const dot = (a: Float32Array, b: Float32Array) => {
  let sum = 0;
  for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
  return sum;
};

const docs = [
  "AbortController e segnali di cancellazione",
  "Debounce e throttle in JavaScript",
  "Content Security Policy: nonce e strict-dynamic",
  "Web Worker: spostare il lavoro fuori dal main thread",
];

const embedder = new Embedder((file, p) => console.log(`${file}: ${p.toFixed(0)}%`));
console.log("Device:", await embedder.ready);

// Calcolato una volta sola, poi eventualmente salvato in IndexedDB
const docVectors = await embedder.embed(docs);

export async function search(query: string, k = 3) {
  const [q] = await embedder.embed([query]);
  return docs
    .map((text, i) => ({ text, score: dot(q, docVectors[i]) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

console.table(await search("come annullo una richiesta fetch"));

Per qualche migliaio di documenti la ricerca lineare è più che sufficiente (sono pochi milioni di moltiplicazioni, meno di un millisecondo). Oltre, conviene un indice approssimato (HNSW) o spostare il calcolo dei punteggi nel worker stesso.

💡 Consiglio

Gli embedding dei documenti non cambiano finché non cambia il testo: salvali in IndexedDB insieme a un hash del contenuto e ricalcola solo quelli modificati. Il modello stesso viene già messo in cache da Transformers.js tramite la Cache API del browser, quindi dal secondo caricamento il download sparisce.

Gestire memoria e dispositivi diversi

Qui sta la vera differenza tra una demo e una funzionalità in produzione. Lo stesso codice girerà su una workstation con 24 GB di VRAM e su un telefono di fascia media con memoria condivisa. Qualche controllo da fare prima di scaricare il modello:

// capabilities.ts
export async function inspectDevice() {
  const gpu = "gpu" in navigator ? await navigator.gpu.requestAdapter() : null;

  const storage = await navigator.storage?.estimate?.();

  return {
    webgpu: !!gpu,
    // Limite del singolo buffer: i pesi di un layer devono starci dentro
    maxBufferSize: gpu?.limits.maxBufferSize ?? 0,
    maxStorageBinding: gpu?.limits.maxStorageBufferBindingSize ?? 0,
    fp16: gpu?.features.has("shader-f16") ?? false,
    // Solo Chromium, arrotondato e limitato a 8 GB per privacy: indicativo
    deviceMemoryGB: (navigator as { deviceMemory?: number }).deviceMemory,
    // Spazio disponibile per la cache del modello
    quotaFreeMB: storage ? ((storage.quota ?? 0) - (storage.usage ?? 0)) / 1e6 : undefined,
  };
}

Con queste informazioni puoi applicare una strategia a livelli:

  1. WebGPU + memoria abbondante → modello più grande o precisione fp32/fp16.
  2. WebGPU con limiti bassi o deviceMemory ≤ 4 → modello piccolo quantizzato (q8/q4).
  3. Niente WebGPU → WASM con modello quantizzato, e magari una UI che avvisa che l’operazione sarà più lenta.
  4. Quota di storage insufficiente o connessione a consumo (navigator.connection?.saveData) → non scaricare nulla automaticamente: chiedi all’utente o ripiega su un’API server.

Un altro evento da gestire è la perdita del device GPU: il driver può resettarsi, il sistema operativo può reclamare memoria, una tab in background può essere penalizzata. Se lavori direttamente con WebGPU, device.lost è una Promise che si risolve quando succede; con Transformers.js o ONNX Runtime la vedrai come un errore durante l’inferenza. In entrambi i casi la strategia è la stessa: termina il worker, creane uno nuovo e ricarica il modello (dalla cache, quindi senza nuovo download).

⚠ Attenzione

Il fallback WASM multi-thread di ONNX Runtime usa SharedArrayBuffer, che richiede una pagina cross-origin isolated (header Cross-Origin-Opener-Policy: same-origin e Cross-Origin-Embedder-Policy: require-corp). Senza questi header il runtime funziona lo stesso, ma a thread singolo — molto più lento. Attenzione: COEP può rompere embed e script di terze parti che non inviano gli header CORP/CORS corretti.

E gli LLM?

Lo stesso approccio si estende ai modelli generativi con WebLLM, che espone un’API compatibile con quella di OpenAI:

import { CreateMLCEngine } from "@mlc-ai/web-llm";

const engine = await CreateMLCEngine("Qwen2.5-1.5B-Instruct-q4f16_1-MLC", {
  initProgressCallback: (p) => console.log(p.text),
});

const reply = await engine.chat.completions.create({
  messages: [{ role: "user", content: "Riassumi in una frase cos'è WebGPU." }],
});
console.log(reply.choices[0].message.content);

WebLLM offre anche CreateWebWorkerMLCEngine per eseguire tutto in un worker con la stessa API. Ma le proporzioni cambiano: un modello da 1-3 miliardi di parametri quantizzato a 4 bit pesa uno o più GB da scaricare e richiede altrettanta memoria GPU. Ha senso per strumenti usati ripetutamente (un editor, un’app interna), molto meno per una landing page visitata una volta.

Quando ha senso (e quando no)

Inferenza client-side conviene quando:

  • il modello è piccolo (decine o poche centinaia di MB) e riutilizzato più volte nella sessione;
  • i dati sono sensibili e non dovrebbero lasciare il dispositivo (documenti, foto, testo privato);
  • vuoi funzionalità offline o local-first;
  • il volume di richieste renderebbe costosa un’API a consumo.

Resta meglio il server quando serve un modello grande e di qualità massima, quando i dispositivi target sono vecchi o poco potenti, o quando il primo utilizzo è anche l’unico e un download di decine di MB non si giustifica.

Domande frequenti

❓ WebGPU funziona dentro un Web Worker?

Sì. navigator.gpu è esposto anche in WorkerNavigator nei Dedicated Worker, quindi download, compilazione degli shader e inferenza possono restare interamente fuori dal main thread.

❓ Cosa succede se il browser non supporta WebGPU?

Transformers.js e ONNX Runtime Web possono usare WebAssembly sulla CPU. Basta scegliere device: "wasm" (o lasciare che il runtime ripieghi) e preferire un modello quantizzato: più lento, ma funziona ovunque.

❓ Il modello viene riscaricato a ogni visita?

No. Transformers.js salva i file del modello nella Cache API del browser: dal secondo caricamento vengono letti in locale. La cache può però essere svuotata dal browser in caso di poco spazio, quindi il codice deve sempre gestire un nuovo download.

❓ Quanto è grande un modello adatto al browser?

Per embedding e classificazione bastano 20-100 MB. Modelli per OCR o speech-to-text vanno da qualche decina a qualche centinaio di MB. Gli LLM, anche piccoli e quantizzati a 4 bit, partono da circa un GB.

Conclusioni

WebGPU ha spostato l’inferenza nel browser da esperimento a opzione architetturale concreta. Il codice del modello è la parte facile — una pipeline di Transformers.js sono tre righe. Il lavoro vero è tutto intorno: tenere il main thread libero con un Web Worker, scegliere modello e precisione in base al dispositivo, gestire cache, fallback e perdita del device. Fatto questo, ottieni funzionalità AI con latenza zero, costo server zero e privacy by design.

Se il tema performance ti interessa, il punto di partenza è l’articolo sui Web Worker in JavaScript; per annullare in modo pulito ricerche ancora in corso quando l’utente continua a digitare, vale lo stesso pattern descritto in AbortController.

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Scritto da

Diego Betto

Co-Fondatore & CTO presso PAPION. Senior full-stack engineer specializzato in React, TypeScript, Node.js e sicurezza applicativa.