Loading a 1.5B-Parameter LLM in the Browser with WebGPU and Transformers.js
Complete Implementation of WebGPU Model Loading
Based on Vite + React + transformers.js, a complete implementation record of loading a 1.5B parameter model in the browser using WebGPU
DeepSeek-R1 is one of the most closely watched open-source reasoning models in recent years. Typically, running such a model requires a Python environment, CUDA drivers, a decent graphics card—or at least a cloud server. But WebGPU changes this premise.
WebGPU is a GPU programming interface exposed by the browser, allowing JavaScript to directly schedule the computing resources of a graphics card. Paired with Hugging Face's transformers.js library, you can load an ONNX-format language model in a regular webpage and perform inference using the GPU, without needing any backend server1.
This article documents the build process of the dpsk-webgpu project: from technology selection and Worker thread architecture, to WebGPU detection and the complete implementation of model downloading. The project currently completes model loading and progress display; the inference part is still under development—but the loading process itself is the foundation of the whole thing and is worth writing out clearly on its own.
Technology Selection
The project was scaffolded with Vite. The tech stack is React 19 + TypeScript, with Tailwind CSS v4 for the UI. The core dependency is a single package: @huggingface/transformers (i.e., transformers.js v4)1.
transformers.js is a JavaScript library officially maintained by Hugging Face. Its API design is highly consistent with the Python-side transformers. You still use from_pretrained to load models and AutoTokenizer for tokenization. The difference is that model files are in ONNX format, and inference is executed via the WebGPU backend. Model weights are cached directly in the browser's Cache Storage, so subsequent loads don't require re-downloading.
JSON
// package.json
{
"dependencies": {
"@huggingface/transformers": "^4.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwindcss": "^4.3.3"
}
}
Why a Worker Thread is Needed
Model downloading and inference are computationally intensive operations. If you call the transformers.js API directly inside a React component, the entire main thread will be blocked—the user sees a completely frozen page, and even the progress bar cannot refresh.
Web Workers are the browser's native multi-threading solution. By placing the model loading and inference logic into worker.js, the main thread is only responsible for rendering the UI and responding to user actions. The two threads communicate via postMessage: the main thread sends commands to the Worker, and the Worker sends progress and status back to the main thread.
The project's architecture is thus divided into two layers:
- App.tsx (Main Thread): Manages UI state, creates the Worker, listens for messages sent back by the Worker
- worker.js (Worker Thread): Detects WebGPU, loads the model, executes inference
Setting Up the Worker Channel
App.tsx uses useRef to hold the Worker instance and completes three things inside useEffect: creation, listening, and cleanup.
TSX
// src/App.tsx
const worker = useRef<Worker | null>(null);
useEffect(() => {
worker.current = new Worker(
new URL('./worker.js', import.meta.url),
{ type: "module" }
);
// Listen for messages sent back by the Worker
worker.current?.addEventListener("message", onMessage);
// Detect GPU first, trigger download after it passes
worker.current?.postMessage({ type: "check" });
return () => {
worker.current?.removeEventListener("message", onMessage);
worker.current?.terminate();
};
}, []);
useRef's role is to keep the Worker instance referenced throughout the component's entire lifecycle, preventing it from being lost due to re-renders. new URL('./worker.js', import.meta.url) is Vite's recommended way of writing this, ensuring the path is correct after building2. type: "module" allows the Worker to run as an ES module, so that import syntax can be used inside it to bring in transformers.js.
The cleanup logic cannot be omitted.
removeEventListenerremoves the message listener, andterminateterminates the Worker thread. Withoutterminate, the Worker will continue to occupy memory; withoutremoveEventListener, residual listeners on a terminated Worker may trigger unexpected errors.
Three States Support the Entire UI
The component only needs three state variables:
TSX
// src/App.tsx
const [message, setMessage] = useState("正在检测 GPU…");
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState(false);
message is the current prompt text, progress is the download percentage (null means not downloading), and error controls whether the text turns red. These three variables cover all possible UI states during the loading process.
The data sent back by the Worker via postMessage carries a status field. The main thread uses switch/case to dispatch handling:
TSX
// src/App.tsx — onMessage callback
const onMessage = (event: MessageEvent) => {
const data = event.data;
switch (data.status) {
case "webgpu-check":
setMessage(data.message);
setError(!data.supported);
if (data.supported) {
// GPU is available, initiate model download
worker.current?.postMessage({ type: "load" });
}
break;
case "loading":
setMessage(data.message);
break;
case "download":
setProgress(data.progress);
break;
case "ready":
setMessage("模型下载完成 ✅");
setProgress(null);
break;
case "error":
setMessage(data.message);
setError(true);
break;
}
};
There is a key design here: Only after the WebGPU check passes does the main thread send the load command to trigger the model download. This sequence ensures that a download of several hundred MB isn't wasted on a browser that doesn't support WebGPU.
WebGPU Detection
After worker.js receives the check command, it calls the checkWebGPU function. The detection logic is in two steps:
JavaScript
// src/worker.js — checkWebGPU
async function checkWebGPU() {
try {
if (!navigator.gpu) {
throw new Error("浏览器没有 WebGPU");
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("没有找到可用的 GPU adapter!");
}
self.postMessage({
status: "webgpu-check",
supported: true,
message: "GPU可用",
});
} catch (err) {
self.postMessage({
status: "webgpu-check",
supported: false,
message: "GPU不可用",
});
}
}
First, check if navigator.gpu exists—this is the entry point for the WebGPU API; its absence means the browser doesn't support it at all3. Then call requestAdapter() to request a GPU adapter; this step actually communicates with the graphics card driver. If no adapter is obtained, it means the API exists but the hardware or drivers don't meet the requirements.
The detection result is sent back to the main thread via postMessage, carrying a supported boolean and a human-readable message.
Model Loading and Progress Reporting
Model loading is encapsulated in the TextGenerationPipeline class, using a static property to implement the singleton pattern:
JavaScript
// src/worker.js — TextGenerationPipeline
class TextGenerationPipeline {
static modelId = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static tokenizer = null;
static model = null;
static async getInstance(progressCallback = null) {
this.tokenizer ??= AutoTokenizer.from_pretrained(this.modelId, {
progress_callback: progressCallback,
});
this.model ??= AutoModelForCausalLM.from_pretrained(this.modelId, {
dtype: "q4f16",
device: "webgpu",
progress_callback: progressCallback,
});
return Promise.all([this.tokenizer, this.model]);
}
}
modelId points to onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX on Hugging Face—the ONNX conversion of the DeepSeek-R1 distilled Qwen-1.5B version. It's small in size and fast, suitable for running in the browser4.
Two key parameters are worth expanding on:
dtype: "q4f16"— 4-bit quantization with 16-bit floating point. Model weights are compressed to one-quarter of their original size. The download size for a 1.5B parameter model is kept within a few hundred MB, manageable for most devices.device: "webgpu"— Tells transformers.js to use the WebGPU backend for inference execution, rather than falling back to WASM. WASM mode, while more compatible, is an order of magnitude slower.
The ??= operator performs a logical nullish assignment: the assignment on the right is only executed when the left side is null or undefined. The tokenizer and model are each loaded once; subsequent calls directly reuse the already loaded instances, avoiding redundant downloads.
The loadModel function is responsible for translating transformers.js's progress callbacks into Worker messages:
JavaScript
// src/worker.js — loadModel
async function loadModel() {
try {
self.postMessage({
status: "loading",
message: "下载模型中…"
});
await TextGenerationPipeline.getInstance((progress) => {
// The "download progress" message status name from transformers.js is "progress" (carrying a progress number field)
// "download" is just a "download started" notification, without a progress field; don't use it
if (progress.status === "progress" && Number.isFinite(progress.progress)) {
self.postMessage({
status: "download",
progress: progress.progress,
});
}
});
ready = true;
self.postMessage({ status: "ready" });
} catch (err) {
self.postMessage({
status: "error",
message: err instanceof Error ? err.message : String(err),
});
}
}
An easy pitfall to step into: transformers.js's
progress_callbackreceives messages of various statuses. Among them, only those withstatusas"progress"carry aprogressnumeric field, representing the download percentage. There is also a"download"status, but it is merely a "download started" notification without a progress value. Directly using theprogressfield from a"download"status message will yieldundefined, and the progress bar will never move.
After the model loading is complete, the ready flag is set to true, and the Worker sends back a ready message. Upon receiving it, the main thread hides the progress bar and displays "Model download complete."
Full Communication Flow
Drawing the message exchange between the main thread and the Worker as a sequence diagram makes the entire process clear at a glance:
Mermaid Source
worker.js (Worker Thread)App.tsx (Main Thread)worker.js (Worker Thread)App.tsx (Main Thread)loop[Download Process]Hide progress bar, show completionShow error, do not trigger downloadalt[GPU Available][GPU Unavailable]postMessage({type:"check"})checkWebGPU(){status:"webgpu-check", supported:true}postMessage({type:"load"})loadModel(){status:"loading", message:"Downloading model…"}{status:"download", progress:xx}{status:"ready"}{status:"webgpu-check", supported:false}
100%
The message format is deliberately kept simple: each message has only one status field plus several data fields. The main thread's switch/case can dispatch directly, without needing complex routing logic. The benefit of this design is easy extensibility—when adding inference functionality later, you only need to add a few status branches like "start", "update", "complete".
Progress Bar Implementation
The UI part uses Tailwind to write a progress bar with a very simple structure:
TSX
// src/App.tsx — JSX rendering
<h1>dpsk-r1 webgpu 学习版</h1>
<p className={error ? "text-red-500" : ""}>{message}</p>
{progress !== null && (
<div>
{/* Gray base track */}
<div className="w-full h-4 bg-gray-200 rounded-full overflow-hidden">
{/* Blue fill, width = download percentage */}
<div className="h-4 bg-blue-500 rounded-full transition-all"
style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 text-sm">下载中 {Math.round(progress)}%</p>
</div>
)}
The outer div is the gray base track. The inner div's width is bound to the progress percentage, and transition-all provides a smooth transition for width changes. When progress is null, the entire block is not rendered—this is the signal that the download has not started or has already completed.
During actual runtime, the interface roughly looks like this:
Plain Text
┌──────────────────────────────────────────┐
│ │
│ dpsk-r1 webgpu 学习版 │
│ │
│ 下载模型中… │
│ │
│ ████████████████░░░░░░░░░░ 67% │
│ 下载中 67% │
│ │
└──────────────────────────────────────────┘
Current Progress
The project's generate branch is currently simulated and does not actually call the model for inference:
JavaScript
// src/worker.js — generate (simulated)
if (type === "generate" && ready) {
self.postMessage({ status: "start" });
self.postMessage({ status: "update", output: `模拟回答:${text}` });
self.postMessage({ status: "complete" });
}
It just echoes the user's input back. The next step is to integrate tokenizer.encode and model.generate to implement real text generation. The UI layer also needs an input box and a conversation area—the current interface only has a title, a line of status text, and a progress bar, which is enough to verify the loading process but still far from a usable chat interface.
However, the foundation is already laid. The Worker channel, state management, WebGPU detection, model loading, and progress reporting—this infrastructure does not need to be rewritten when integrating inference; only the real inference logic needs to be filled into the generate branch. At that point, this project will transform from a "loading demo" into a browser-side AI application capable of real conversation.
Footnotes
- Hugging Face, Transformers.js Documentation. https://huggingface.co/docs/transformers.js ↩ ↩2
- Vite, Web Worker Support. https://vite.dev/guide/features.html#web-workers ↩
- MDN Web Docs, WebGPU API. https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API ↩
- onnx-community, DeepSeek-R1-Distill-Qwen-1.5B-ONNX. https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX ↩