LLMs in the browser with WebGPU
How WebLLM, transformers.js and WASM runtimes work, what WebGPU gives you, memory and download limits, a worker-based implementation with feature detection and fallback, caching, UX patterns and a deployment checklist.
Browsers can now run small LLMs entirely client-side: no server, no API key, no data leaving the device. That unlocks zero-install demos, offline tools and privacy-preserving features, with the constraints of a sandboxed, memory-limited environment. This lesson explains how it works and how to ship something solid.
#What WebGPU is
WebGPU is the modern browser API for the GPU. Unlike WebGL, which was designed for drawing, WebGPU supports general-purpose compute shaders, so matrix multiplications for LLMs run on the GPU. It works through your normal browser permissions: no install, no drivers to manage, and the page cannot reach beyond the sandbox.
Availability changes quickly. It is in Chromium-based browsers on supported platforms, and arriving in recent Safari and Firefox builds, with gaps on some devices and operating systems. Never assume it exists: feature-detect.
#The stack
| Project | Approach | Notes |
|---|---|---|
| WebLLM (MLC) | Models compiled to WebGPU; OpenAI-style JS API | Best speed; a curated list of prebuilt models |
| transformers.js | Hugging Face models via ONNX Runtime (WebGPU or WASM) | Broad model support: embeddings, classifiers, small LLMs |
| wllama | llama.cpp compiled to WebAssembly (CPU, no WebGPU needed) | Runs where WebGPU is missing; slower |
The same ideas as native runtimes apply: weights in a compact quantised format (typically 4-bit), a tokenizer, a KV cache and a sampling loop. The difference is where it runs and how it is delivered.
#WebLLM in a few lines
import { CreateMLCEngine } from "@mlc-ai/web-llm";
const engine = await CreateMLCEngine("Qwen2.5-0.5B-Instruct-q4f16_1-MLC", {
initProgressCallback: (p) => console.log(p.text),
});
const reply = await engine.chat.completions.create({
messages: [{ role: "user", content: "Hello!" }],
});
console.log(reply.choices[0].message.content);Try it live in the playground. Model identifiers and the API evolve, so check the project's current documentation for the exact model list and options.
#Limits to design around
- Download size: 300 MB to 2 GB on first visit. Weights are cached afterwards (Cache Storage or IndexedDB), but a first-time visitor on mobile data will notice.
- GPU memory per tab is limited, and browsers cap single buffers and total allocations. Stay at 3B or smaller for broad compatibility; 7B+ models work only on powerful desktops.
- Speed: expect a fraction of native speed. A 0.5 to 1B model may produce tens of tokens per second on a decent laptop GPU; larger ones are much slower. Prompt processing is relatively slow on integrated GPUs.
- Browser support: WebGPU is not everywhere. Always feature-detect
navigator.gpu. - Run it in a Web Worker so the UI stays responsive.
- Mobile browsers have tighter memory limits and may reload the tab under pressure, losing the loaded model.
- Background tabs are throttled. Long generations in a hidden tab can stall.
- Storage quotas: browsers limit how much a site can cache; large models may be evicted. Request persistent storage where appropriate.
- Security headers for WASM threads: multi-threaded WebAssembly needs cross-origin isolation (COOP and COEP headers), which affects embedding third-party content.
#Good use cases
Privacy-sensitive summarisation, offline tools, product demos, educational sandboxes, classification and rewriting features where you do not want server cost, and "try before you install" experiences. Poor fits: anything needing a large model, heavy reasoning, long contexts or shared state between users.
#Feature detection and fallback
async function pickBackend() {
if ("gpu" in navigator) {
try {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) return "webgpu";
} catch {}
}
return "wasm"; // slower CPU runtime, or fall back to a server API
}Fallback options, in order of preference: a smaller model on WebGPU, a WASM runtime (slower but works), a server API (with consent and a privacy notice), or a clear message explaining what is unsupported. Test in at least one browser without WebGPU.
#A worker-based implementation
Keep the engine off the main thread:
// llm.worker.ts
import { WebWorkerMLCEngineHandler } from "@mlc-ai/web-llm";
const handler = new WebWorkerMLCEngineHandler();
self.onmessage = (msg: MessageEvent) => handler.onmessage(msg);// main.ts
import { CreateWebWorkerMLCEngine } from "@mlc-ai/web-llm";
const engine = await CreateWebWorkerMLCEngine(
new Worker(new URL("./llm.worker.ts", import.meta.url), { type: "module" }),
"Qwen2.5-0.5B-Instruct-q4f16_1-MLC",
{ initProgressCallback: (p) => updateProgressBar(p.progress, p.text) },
);
const stream = await engine.chat.completions.create({
messages: [{ role: "user", content: "Summarise: ..." }],
stream: true,
});
for await (const chunk of stream) {
appendToUI(chunk.choices[0]?.delta?.content ?? "");
}Streaming the reply token by token makes a slow model feel responsive.
#Performance expectations
Rough numbers, which vary enormously by device and browser:
| Setup | Typical experience |
|---|---|
| 0.5B model on a laptop with integrated GPU | Tens of tokens per second; fine for short tasks |
| 1 to 3B on a discrete GPU laptop | Comfortable chat speed |
| 7B on a powerful desktop GPU | Possible, but a very large download and memory use |
| WASM CPU runtime, 0.5 to 1B | A few tokens per second |
The same bandwidth rule applies: speed is about memory bandwidth divided by model size (memory bandwidth), minus browser overhead. Measure on the lowest-end device you intend to support (measuring speed).
#UX patterns that work
- Ask before downloading. Show the size ("Download a 600 MB model for offline use?"), and let the user cancel.
- Show real progress, and explain that it only happens once.
- Cache and verify. After the first load, start instantly; if the cache is evicted, handle re-download gracefully.
- Stream the output, and provide a Stop button.
- Disable the input until the model is ready, and show a clear loading state.
- Persist the conversation locally if appropriate, but tell users where data lives.
- Offer a lighter model for weak devices, chosen automatically from detected memory when possible.
- Handle tab reloads and crashes: the model must reload; do not lose user input.
#Privacy and security
- Data stays on the device, but only if your own page does not send it elsewhere: analytics, error trackers and third-party scripts can capture prompts. Audit them.
- Model provenance: models fetched from a CDN are code-adjacent data. Pin versions and use subresource integrity or checksums where possible, and host the files yourself for production (supply chain).
- Prompt injection applies as usual if the page feeds untrusted content to the model (prompt injection); a browser-based model with no tools has a small blast radius, which is a real advantage.
- Licences: check that the model licence permits redistribution through your site (licences).
#Deployment checklist
- Feature detection and a tested fallback
- Engine runs in a Web Worker
- Download size shown, with progress and cancel
- Weights cached, with a plan for eviction
- Self-hosted model files with versions pinned and integrity checked
- Tested on a low-end laptop and a mid-range phone
- No third-party scripts that capture prompts
- A clear statement of what stays on the device
- Memory use checked in the browser's task manager
#Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "WebGPU not supported" | Browser, OS or GPU lacks it | Detect, fall back, show a message |
| Tab crashes while loading | Model too large for the device | Smaller model or quant |
| Page freezes during generation | Engine on the main thread | Use a Web Worker |
| Slow every visit | Cache not persisted or evicted | Check storage settings; request persistence |
| Garbled output | Mismatched model and chat template | Use the library's prebuilt, matched models |
| Works on desktop, fails on phone | Memory limit | Offer a smaller model |
#Common mistakes
- Picking a model too big for typical user devices.
- Forgetting feature detection.
- Running on the main thread.
- Auto-downloading hundreds of MB without consent.
- Testing only on a development machine with a discrete GPU.
- Sending prompts to analytics.
#Exercise
You are building an offline "summarise this page" browser extension for general users.
- Which model size would you target and why?
- List three things you would add to protect the user experience.
Answers:
- A 0.5 to 1.5B model at 4-bit (about 350 MB to 1 GB): it fits in most devices' GPU memory limits, downloads in reasonable time and is fast enough for short summaries.
- Ask before downloading and show progress, run the model in a worker with streaming output and a Stop button, and feature-detect WebGPU with a WASM or "not supported" fallback. Also cache the model and pin its version.
Quick quiz
+10 XP per correct answer, +25 XP for a perfect score
1. WebGPU gives browsers access to...
2. The biggest UX cost of in-browser LLMs is usually...
3. Why run the engine in a Web Worker?
4. A user's browser lacks WebGPU. What is the best behaviour?
5. Which is a good match for an in-browser model?