# 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.

- Autore / Author: Diego Betto (https://diegobetto.com/about/)
- Pubblicato / Published: 2026-09-29
- URL: https://diegobetto.com/webgpu-ai-nel-browser/
- Tag: javascript, typescript, ai, webgpu, performance

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.

```bash
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:

```json
// 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](https://diegobetto.com/web-worker-javascript/) e che si traduce in un INP pessimo nei [Core Web Vitals](https://diegobetto.com/core-web-vitals-lcp-cls-inp/).

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:

```ts
// 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

```ts
// 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

```ts
// 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

```ts
// 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.

> 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:

```ts
// 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).

> 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:

```ts
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](https://diegobetto.com/web-worker-javascript/); per annullare in modo pulito ricerche ancora in corso quando l'utente continua a digitare, vale lo stesso pattern descritto in [AbortController](https://diegobetto.com/abortcontroller-fetch-javascript/).
