跪拜 Guibai
← Back to the summary

A Full-Stack Breakdown of Running DeepSeek-R1 Locally in the Browser with WebGPU

Running DeepSeek-R1 in the Browser from Scratch: A Full-Stack Practical Guide with WebGPU + Transformer.js

Foreword

Hello everyone, I am a frontend developer. I recently built a project referencing GitHub — running the DeepSeek-R1 1.5B inference model locally in the browser, requiring no backend server, with all AI computation completed on the user's own device.

This article will strictly follow the order of my own study notes, dissecting layer by layer: first explaining what the model community and Transformer.js are, then analyzing the role of each npm package, and then diving deep into the Web Worker multi-threading architecture, WebGPU hardware acceleration, the TypeScript type system, and finally explaining the design philosophy of the Singleton pattern. I will expand on every piece of code and every comment.


1. HuggingFace: The Open-Source Model Community in the AI World

Original Note:

# webgpu-deepseek
## huggingface
The hottest open-source model community in the AI circle, where various vendors publish their AI models
modelscope

1.1 What is a Model Community

In the AI era, Large Language Models (LLMs) are equivalent to "third-party libraries" in traditional software development. In traditional development, if we need a date processing library, we search on npm/pip; in AI development, if we need a model, we search in a model community.

We can understand a model community as a giant "App Store for AI Models":

Traditional Software Development                AI Development
─────────                                      ──────
npm install lodash                             pipeline("text-generation", "model-id")
pip install requests                           AutoModel.from_pretrained("model-id")

Developers upload trained models, and others can download and use them directly via a Model ID, saving the huge cost of training from scratch.

1.2 HuggingFace and ModelScope

Platform Positioning Company Behind Features
HuggingFace World's largest open-source model community HuggingFace (USA) Most models, most active community
ModelScope (MoDa) China's largest model community Alibaba Cloud Fast domestic access, many Chinese models

The note mentions ModelScope because accessing HuggingFace from China often encounters network issues; ModelScope is very important as a domestic mirror alternative. Transformer.js also supports pulling model files from ModelScope.

1.3 Model ID: The Model's "ID Card"

In our worker.js, there is this core configuration line:

// worker.js line 11
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";

Breaking down this ID:

onnx-community    /    DeepSeek-R1-Distill-Qwen-1.5B-ONNX
      ↑                           ↑
  Organization/Username      Model Repository Name
                              ├── DeepSeek-R1: Base model
                              ├── Distill: Distilled version (large model → small model)
                              ├── Qwen-1.5B: Based on Alibaba's Qwen architecture, 1.5 billion parameters
                              └── ONNX: Model format (not PyTorch, but cross-platform ONNX)

The corresponding real HuggingFace address:

https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX

How the framework uses this ID internally:

After Transformer.js gets the model_id, it automatically concatenates all required file URLs:
  
{model_id}/resolve/main/
  ├── tokenizer.json           ← Vocabulary file, mapping text to numeric IDs
  ├── tokenizer_config.json    ← Special configuration for the tokenizer
  ├── config.json              ← Model architecture parameters (number of layers, hidden dimensions, etc.)
  └── onnx/
      └── model.onnx           ← All weights of the neural network (quantized ONNX format)

Design Intent: Developers only need to remember one ID; the framework automatically discovers and downloads all dependent files. This is "convention over configuration" — HuggingFace specifies the file structure of the model repository, and Transformer.js looks up according to this convention, eliminating the need for developers to manually specify the URL for each file.


2. Transformer.js: The Core Engine Enabling Models to Run in the Browser

Original Note:

transform.js
web access id remote download, access, and execute nlp tasks
scenarios

2.1 What is Transformer.js

Transformer.js (npm package @huggingface/transformers) is an official JavaScript library launched by HuggingFace. What it does can be summarized in one sentence: It lets you run Transformer architecture AI models directly in the browser using JavaScript.

Under the hood, it relies on ONNX Runtime Web — a computation engine capable of executing ONNX format neural networks in the browser. ONNX Runtime Web can choose between two backends:

Two inference backends for ONNX Runtime Web:

┌──────────────────────────────────────────────┐
│ Backend Selection                            │
│                                              │
│  WebGPU Backend (Our choice)                 │
│  ├─ Directly calls GPU hardware acceleration │
│  ├─ Inference Speed: Fast (5-20x CPU)        │
│  └─ Requirement: Browser supports WebGPU API │
│                                              │
│  WebAssembly Backend (Fallback)              │
│  ├─ Simulates execution on CPU               │
│  ├─ Inference Speed: Slow                    │
│  └─ Requirement: Supported by almost all browsers │
└──────────────────────────────────────────────┘

2.2 Its API is Almost Identical to the Python Version

The design goal of Transformer.js is to allow developers familiar with Python Transformers to switch to the browser side with zero learning cost:

# Python Transformers
from transformers import AutoTokenizer, AutoModelForCausalLM

tokenizer = AutoTokenizer.from_pretrained("model-id")
model = AutoModelForCausalLM.from_pretrained("model-id")
// JavaScript version — Syntax is completely consistent, only import syntax differs
import { AutoTokenizer, AutoModelForCausalLM } from "@huggingface/transformers";

const tokenizer = await AutoTokenizer.from_pretrained("model-id");
const model = await AutoModelForCausalLM.from_pretrained("model-id");

Design Intent: HuggingFace deliberately maintains high consistency between the two sets of APIs. This means the vast amount of Python Transformers tutorials and example code in the community can almost be directly translated to the browser side. This is a key design decision to reduce the migration cost for developers.

2.3 Two Core Concepts: Tokenizer and Model

To understand what Transformer.js does, one must first understand these two concepts:

┌─────────────────────────────────────────────────────────┐
│                  Tokenizer                              │
│                                                         │
│  Role: Converts between "human text" and "numbers the model understands" │
│                                                         │
│  Encode: Text → Number Sequence                         │
│  "Hello, world" ──→ [108386, 104738, 111419]            │
│                                                         │
│  Decode: Number Sequence → Text                         │
│  [108386, 104738, 111419] ──→ "Hello, world"            │
│                                                         │
│  File Size: ~1MB                                        │
│  Underlying: A huge mapping table (vocabulary) recording the numeric ID for each word │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                  Model (The Model Itself)                │
│                                                         │
│  Role: Receives token IDs, predicts the next most likely token │
│                                                         │
│  Input: [108386, 104738]  ("Hello,")                    │
│          ↓                                              │
│  Output: Probability distribution → Sampling → [111419] ("world") │
│                                                         │
│  File Size: ~800MB (after quantization)                 │
│  Underlying: A massive matrix multiplication network (Transformer architecture) │
└─────────────────────────────────────────────────────────┘

These two components are always used together: the Tokenizer is responsible for "translation," and the Model is responsible for "thinking."


3. The DeepSeek-R1 Model: The Complete Data Pipeline

Original Note:

deepseek deepseek-r1-distill-qwen 1.5B file upload (GB) -> huggingface
-> transform.js -> load -> web download to browser local (slow) -> browser cache
-> webgpu (new feature, compatibility) -> nlp task

3.1 Why Step by Step? — Breaking Down the Complete Pipeline

This pipeline diagram covers the entire process from "the model on the developer's computer" to "the model executing NLP tasks in the user's browser." Let's break it down step by step:

Step 1: File Upload → HuggingFace
┌────────────────────────────────────────────┐
│ DeepSeek team trains the model             │
│ Original format: PyTorch (.safetensors)    │
│ File size: ~6GB (fp32) / 3GB (fp16)       │
│                                            │
│ onnx-community converts it to ONNX format  │
│ and applies INT4 quantization → file shrinks to ~800MB │
│                                            │
│ Uploaded to HuggingFace, assigned Model ID: │
│ "onnx-community/DeepSeek-R1-Distill-       │
│  Qwen-1.5B-ONNX"                           │
└────────────────────────────────────────────┘

Step 2: Transformer.js load
┌────────────────────────────────────────────┐
│ User clicks "Load model" in the browser    │
│                                            │
│ App.tsx sends command:                     │
│ worker.current.postMessage({ type: "load" })│
│                                            │
│ Worker calls:                              │
│ TextGenerationPipeline.getInstance()       │
│   ├─ AutoTokenizer.from_pretrained(id)     │
│   │   → Downloads tokenizer.json (~1MB)    │
│   └─ AutoModelForCausalLM.from_pretrained()│
│       → Downloads model.onnx (~800MB)      │
│                                            │
│ Triggers progress_callback every 16KB during download │
│ → Worker postMessage → Main thread updates progress bar │
└────────────────────────────────────────────┘

Step 3: Web Download to Browser Local (Slow) → Browser Cache
┌────────────────────────────────────────────┐
│ First visit: Download 800MB (slow, 1-5 mins) │
│                                            │
│ Transformer.js internally uses IndexedDB for persistent caching │
│ Second visit: Reads directly from IndexedDB (instant) │
│                                            │
│ This is why the note marks "(slow)":       │
│ Poor first-load experience, but excellent after caching │
└────────────────────────────────────────────┘

Step 4: WebGPU (New Feature, Compatibility)
┌────────────────────────────────────────────┐
│ Browser calls GPU hardware-accelerated inference via WebGPU API │
│                                            │
│ If WebGPU is not supported:                │
│   → Falls back to WebAssembly (CPU inference, 5-20x slower) │
│   → 1.5B model on CPU might be too slow to be usable │
│                                            │
│ Current Compatibility:                     │
│ Chrome 113+ ✅ | Edge 113+ ✅ | Safari ✅   │
│ Firefox Nightly ⚠️ | Older browsers ❌      │
└────────────────────────────────────────────┘

Step 5: Execute NLP Tasks
┌────────────────────────────────────────────┐
│ Text generation, math reasoning, code generation, logical reasoning, etc. │
│ All computation is done locally on the user's device; data never leaves the device │
└────────────────────────────────────────────┘

3.2 Why the Note Marks "(Slow)" and "Browser Cache"

These are two interrelated key points:

Reason for "Slow": Even after quantization, a 1.5B parameter model is a large file of about 800MB. Under domestic network conditions, downloading from HuggingFace CDN might take 2-10 minutes. This is the biggest experience bottleneck for "browser-side AI."

"Browser Cache" is the Solution: Transformer.js internally uses the browser's IndexedDB to cache downloaded model files. IndexedDB is a local database API provided by the browser, with storage capacity much larger than localStorage (hundreds of MB to several GB).

First Visit:                         Subsequent Visits:
Network download 800MB ──→ IndexedDB       IndexedDB ──→ Direct read (milliseconds)
                  (persistent storage)     (no network request)
      ↓ Slow                                    ↓ Fast
  User waits 2-10 minutes                 User opens instantly

Design Intent: Sacrifice the first-load experience in exchange for a smooth subsequent experience. This is similar to the PWA strategy of "install once, use offline thereafter."


4. Installing Dependencies: What Each npm Package Does

Original Note:

## Install Dependencies
- @huggingface/transformers
  js version of the transformers library, used to load models and perform inference.
- "marked": "^15.0.5",
  aigc returns text in markdown format, which is beneficial for representing certain formatting in text,
  such as code, bold, quotes, etc.
  Before displaying on the page, the md format needs to be converted to html format for display in the browser.
  More concise
  # <h1></h1>

4.1 @huggingface/transformers: The Inference Engine for the Browser

"@huggingface/transformers": "^4.2.0"

This is the core of the entire project; without it, there is no model inference in the browser. It does several things:

  1. Model Discovery: Automatically concatenates file URLs from the HuggingFace CDN based on the model ID.
  2. Model Download: Downloads ONNX format model weight files from the CDN, supporting resumable downloads and progress callbacks.
  3. Model Loading: Loads the ONNX file into ONNX Runtime Web, creating an inference session.
  4. Inference Execution: Provides high-level APIs (pipeline, generate) and low-level APIs (model._forward()).
  5. Hardware Selection: Automatically detects and selects the optimal backend (WebGPU > WebAssembly).

4.2 marked: The "Translator" for Markdown to HTML

"marked": "^15.0.5"

The original note states: "aigc returns text in markdown format, which is beneficial for representing certain formatting in text, such as code, bold, quotes, etc."

The content generated by AI large models is plain text in Markdown format by default. This is because:

Reason One (The core point in the note): Markdown is more concise

Markdown Syntax                  HTML Syntax
─────────────                    ─────────
# Title                          <h1>Title</h1>
**Bold**                         <strong>Bold</strong>
- List item                      <ul><li>List item</li></ul>
`Code`                           <code>Code</code>

To represent the same large title, Markdown only needs 2 characters #, while HTML needs 4 tag characters <h1></h1>. In scenarios generating tens of thousands of tokens, using Markdown saves tokens, inference time, and bandwidth compared to HTML. Moreover, # Title is a format humans are accustomed to using when writing, and models learned it from human-written content.

Reason Two: Markdown is suitable for expressing structured text

AI responses often include code blocks, lists, quotes, tables, etc. Markdown's syntax is designed for this:

Here is a piece of Python code:
```python
def hello():
    print("Hello, World!")

Writing these formats in Markdown is extremely natural, while writing them in HTML is very verbose.

**"Before displaying on the page, the md format needs to be converted to html format for display in the browser."**

This is a crucial step — the browser only understands HTML, not Markdown. If you directly insert the string `# Title` into the page, the browser will only display it as plain text, not render it as a large title.

```js
// Without conversion
element.innerText = "# Title";
// Page displays: # Title  ← This is just plain text, not a title style

// Convert with marked
const html = marked.parse("# Title");
// html = "<h1>Title</h1>"
element.innerHTML = html;
// Page displays: A large "Title" ← This is what we want

4.3 The Complete Markdown Rendering Pipeline

┌────────────────────────────────────────────────────────────┐
│              Markdown Rendering Pipeline                   │
│                                                            │
│  ① AI model outputs Markdown text                          │
│     "## Solution Steps\n\n1. First...\n2. Then...\n\n```python..." │
│     │                                                      │
│     ↓  marked.parse(markdown)                              │
│     │                                                      │
│  ② marked converts Markdown syntax to HTML tags            │
│     "## Solution Steps"  → "<h2>Solution Steps</h2>"       │
│     "1. First..."   → "<ol><li>First...</li>"             │
│     "```python"   → "<pre><code class="python">"         │
│     │                                                      │
│     ↓  DOMPurify.sanitize(html)                            │
│     │                                                      │
│  ③ DOMPurify filters potentially dangerous tags (XSS defense) │
│     Removes <script>, <iframe>, onclick, etc.              │
│     │                                                      │
│     ↓  element.innerHTML = safeHTML                        │
│     │                                                      │
│  ④ Browser parses HTML, renders formatted content with styles │
│     User sees beautifully typeset response                 │
│                                                            │
└────────────────────────────────────────────────────────────┘

Three Layers of Defense:

Layer Tool Responsibility
Markdown Output AI Model Naturally concise format, limited syntax, cannot embed executable scripts
Syntax Conversion marked Whitelist conversion with fixed rules, won't create dangerous tags out of thin air
Security Filtering DOMPurify Fallback protection, intercepts even if the first two layers have vulnerabilities

5. Introducing Web Worker: Multi-threading Architecture

Original Note:

## Introduce webworker
Personal introduction, talk about my project webgpu-deepseek
How to learn? Read 'JavaScript You Don't Know', communities like Juejin,
follow some AI bloggers, github source code, output content to the community.

The note here seems more like self-planning; I will expand on the Web Worker content in conjunction with the code.

5.1 The Problem: JavaScript is Single-threaded

The browser's JavaScript engine has only one main thread. It is simultaneously responsible for rendering the page, handling user interactions, and executing JavaScript code. If the main thread is occupied by a time-consuming task (like inferencing a 1.5B parameter model), all UI will freeze — clicks won't respond, scrolling won't work, animations will stop.

Without Worker:

┌─────────────────────────────────────────┐
│            Main Thread (Only)           │
│                                         │
│  Render UI ✅ → Handle Click ✅ → Model Inference 🔴 │
│                              │          │
│                    Occupies main thread for 10-60 seconds │
│                    All UI completely frozen during this time │
│                              │          │
│                    Browser popup:        │
│                    "Page Unresponsive" 💀 │
└─────────────────────────────────────────┘

5.2 The Solution: Web Worker Independent Thread

Web Worker is a multi-threading capability provided by HTML5. It creates a completely independent JavaScript runtime environment with its own event loop, executing in parallel with the main thread.

With Worker:

┌── Main Thread (UI Thread) ──────────────┐  ┌── Worker Thread ──────────┐
│                                         │  │                          │
│  Event Loop                             │  │  Event Loop              │
│  ├─ Render React components             │  │  ├─ Receive main thread commands │
│  ├─ Respond to user clicks, scrolls     │  │  ├─ Download model files  │
│  ├─ Animations (progress bar, loading state) │  ├─ Model inference (token by token) │
│  └─ Update DOM                          │  │  └─ Return inference results │
│                                         │  │                          │
│  Has: window, document, DOM API         │  │  No: window, document    │
│  Can: Manipulate page, bind events      │  │  Can: Pure computation, network requests │
│                                         │  │                          │
│          ↕ postMessage Communication ↕  │  │                          │
└─────────────────────────────────────────┘  └──────────────────────────┘

5.3 Worker Creation — Key Code Analysis

In App.tsx, the Worker is created inside a useEffect:

// App.tsx lines 24-31
useEffect(() => {
  if (!worker.current) { // Instantiate only once
    // html5 new feature
    worker.current = new Worker(new URL("./worker.js", import.meta.url), {
      type: "module", // Frontend doesn't support esm by default
    });
    // Message communication
    worker.current.postMessage({ type: "check" }); // Do a feature check
  }
  // ... bind event listeners
}, []);

Line-by-line breakdown:

if (!worker.current) — Guard Condition

if (!worker.current) { // Instantiate only once

The Worker only needs to be created once. useRef(null) initializes .current to null. After creating the Worker, it assigns it. On subsequent component re-renders, worker.current already has a value, so this condition check skips, preventing duplicate creation.

The key reason for using useRef instead of useState here: Changes to the Worker instance value do not need to trigger a UI re-render. Changing .current on useRef does not trigger a re-render, whereas useState's setter does. What we need is a "persistent reference," not "reactive state."

new Worker(new URL("./worker.js", import.meta.url)) — Dynamic Path

new Worker(new URL("./worker.js", import.meta.url), { ... });

Why not just write new Worker("./worker.js")?

Because bundlers (Vite) add hashes to filenames during production builds:

Development: ./worker.js                     ← Filename unchanged
Production:  ./worker-a3b8f2c1.js             ← Hash added for cache invalidation

If you hardcode the string "./worker.js", Vite won't know this is a file dependency, and the production environment won't be able to reference the correctly pathed file.

new URL("./worker.js", import.meta.url) lets Vite recognize: "Oh, this file is referenced," and automatically replace it with the correct hashed path during bundling. import.meta.url is the URL of the current module, and new URL() resolves the absolute URL of the Worker file relative to it.

type: "module" — ES Module Mode

{ type: "module", // Frontend doesn't support esm by default }

Workers have two modes:

Classic Worker (Default, without type: "module"):
  self.importScripts("lib.js");  // ← Can only use this old method to load external scripts
  // Does not support import / export syntax
  // Non-strict mode
  // Does not support top-level await

Module Worker (New, with type: "module"):
  import { AutoTokenizer } from "@huggingface/transformers";
  // Supports standard ES Module import/export
  // Automatic strict mode
  // Supports top-level await

Our worker.js uses import { ... } from "@huggingface/transformers", so it must use Module mode; otherwise, the browser will throw a SyntaxError: Cannot use import statement outside a module.

worker.current.postMessage({ type: "check" }) — Environment Pre-check

worker.current.postMessage({ type: "check" }); // Do a feature check

As soon as the Worker is created, a check command is sent to let it check if WebGPU is available. This is called a Pre-check — without waiting for user action, the environment is checked first, so by the time the user clicks "Load model," the conclusion is already there.

5.4 Message Routing on the Worker Side

// worker.js lines 64-86
// Event listener
self.addEventListener("message", async (e) => {
  const { type, data } = e.data;

  switch (type) {
    // Check if webgpu is supported
    case "check":
      check();
      break;
    // Load model
    case "load":
      load();
      break;
    // Generate text
    case "generate":
      break;
    // Interrupt generation
    case "interrupt":
      break;
    // Reset model
    case "reset":
      break;
  }
});

Line-by-line breakdown:

self.addEventListener("message", ...)

self in a Worker points to the Worker's own global scope. Workers don't have window (no DOM environment), so self is used instead. This listener is the sole entry point for the Worker to receive messages from the main thread.

const { type, data } = e.data;

This is ES6 destructuring assignment. e is a MessageEvent object, and e.data is the data sent by the main thread via postMessage. It destructures the two variables type (command type) and data (additional data).

switch (type) — Message Dispatcher

This is essentially a simple RPC router. It's exactly the same design as HTTP backend routing:

Worker Message Routing               HTTP Routing
───────────────                      ──────────
case "check"    → check()            GET  /api/check    → check()
case "load"     → load()             POST /api/load     → load()
case "generate" → generate()         POST /api/generate → generate()
case "interrupt"→ interrupt()        POST /api/interrupt→ interrupt()
case "reset"    → reset()            POST /api/reset    → reset()

Design Intent: Use the type field to distinguish message types, and the receiver dispatches to different handler functions based on type. All Worker communication in the entire system relies on this protocol.

5.5 The State Machine on the Main Thread Side

// App.tsx lines 34-66
const onMessageReceived = (e) => {
  switch (e.data.status) {
    // Downloading
    case "loading":
      setStatus("loading");
      setLoadingMessage(e.data.data);
    break;
    // Initiating a file download
    case "initiate":
    break;
    // Download progress
    case "progress":
    break;
    // Download complete
    case "done":
    break;
    // All files downloaded, ready to use
    case "ready":
    break;
    // Start generation
    case "start":
    break;
    // Streaming, content arriving
    case "update":
    break;
    // Generation complete
    case "complete":
    break;
    // Worker error
    case "error":
      setError(e.data.data);
    break;
  }
}

This is an annotated definition of the Worker message protocol. Each status represents the current phase the Worker is in:

Worker State Flow:

loading ──→ initiate ──→ progress ──→ progress ──→ ... ──→ done ──→ ready
  (Start)     (New file)  (Downloading) (Downloading)        (Done)    (Ready)

ready ──→ start ──→ update ──→ update ──→ ... ──→ complete
(Ready)   (Start gen) (Token received) (Token received)    (Generation end)

Any moment ──→ error (Error occurred)

Design Intent: The main thread doesn't care how the Worker is implemented internally; it only updates the React state based on the status field. State changes → UI updates automatically → User sees the correct interface. This is the cooperation between React's declarative UI and state machine design.


6. WebGPU Detection: navigator.gpu and TypeScript

Original Note:

## !!(navigator as any).gpu
navigator.gpu reports an error, relatively new, experimental stage property, ts doesn't recognize the Navigator class well
ts understanding and learning
navigator as any
as type assertion,
any ts's native type, any type, don't overuse, it will proliferate.
Used to ignore ts type checking.
Other ways?
ts has dedicated type declaration files, @types/webgpu essentially missing type declaration file
pnpm i -D @types/webgpu install type declaration file dev dependency
Use ts during development, js after code packaging

6.1 Problem Description

navigator.gpu is the entry property for the WebGPU API. But this API is relatively new (stabilized in Chrome only in 2023), and TypeScript's built-in type declaration files might not have it yet. Writing navigator.gpu directly will cause the TS compiler to report a type error.

6.2 Detailed Explanation of Three Solutions

Solution 1: as any Type Assertion (The method mentioned in the note)

// App.tsx line 5
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

Layer-by-layer breakdown:

(navigator as any)as any is TypeScript's Type Assertion. It tells the TS compiler: "Treat navigator as the any type, don't perform type checking on it."

any is a special type in TypeScript, meaning "any type." When a variable is declared as any, you can access any of its properties without TS reporting an error — because any means type checking is abandoned.

!! is the double NOT operator, converting any value to a boolean:

navigator.gpu          // undefined (not supported) or GPU object (supported)
!navigator.gpu         // true (not supported) or false (supported)
!!navigator.gpu        // false (not supported) or true (supported)

The note reminds: "any don't overuse, it will proliferate." This is because any makes TypeScript lose its type-checking ability. You can call any non-existent method or access any non-existent property on an any type, and TS won't warn you — this is equivalent to giving up the purpose of using TypeScript.

as any is suitable for temporarily bypassing type checks (e.g., rapid prototyping, incomplete third-party library types), but it shouldn't be a long-term solution.

Solution 2: Install Type Declaration File (Recommended Solution)

pnpm i -D @types/webgpu  # Install type declaration file, dev dependency

@webgpu/types is the official TypeScript type declaration package for WebGPU. It uses TypeScript's declaration file (.d.ts) mechanism to tell the TS compiler what the type of navigator.gpu is.

The note specifically mentions: "dev dependency" — because TypeScript's type declarations are only useful during the compilation phase; after packaging into JS, the type information is completely erased. So it's placed in devDependencies rather than dependencies.

Solution 3: Write Your Own Declaration File

You can also manually extend the Navigator interface:

declare global {
  interface Navigator {
    gpu?: GPU;
  }
}

Applicable scenarios for the three methods:

Method Scenario Risk
as any Temporary debugging, rapid prototyping Loss of type safety
Manual Declaration A few properties missing types Requires maintenance
@webgpu/types Formal projects None, recommended

6.3 The Two-Layer Detection Mechanism

There are two levels of WebGPU detection in the project:

First Layer: App.tsx — Quick detection outside the component

// App.tsx line 5
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;

This line of code is outside function App(), meaning it executes only once in the entire application lifecycle and won't be recalculated on component re-renders. If it returns false, an error page is displayed directly, and subsequent Worker creation and model downloading won't happen — Fail Fast.

Second Layer: worker.js — Deep detection at the adapter level

// worker.js lines 30-49
async function check() {
  try {
    // window 
    // DOM Document Object Model  document
    // BOM Browser Object Model navigator 
    // adapter is an abstraction of the GPU adapter,
    // All subsequent WebGPU computation/rendering operations are executed through the device
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
      // Throw error
      throw new Error("WebGPU is not supported (no adapter found)");
    }
    // fp16_supported = adapter.features.has("shader-f16")
  } catch (e) {
    self.postMessage({
      status: "error",
      data: e.toString(),
    });
  }
}

Code comment analysis line by line:

// window — There is no window object in a Worker. The Worker's global scope is self.

// DOM Document Object Model document — The comment is comparing DOM and BOM:

// adapter is an abstraction of the GPU adapter — The GPUAdapter object returned by requestAdapter() represents a physical GPU. It is a wrapper for the operating system's GPU driver, and all subsequent WebGPU operations (creating devices, allocating video memory, executing compute shaders) are performed through the GPUDevice it creates.

// All subsequent WebGPU computation/rendering operations are executed through the device — adapter is "identifying the GPU," device is "using the GPU":

adapter.requestDevice() → GPUDevice
  ↓                           ↓
"This computer has an NVIDIA RTX 4060"   "I want to allocate memory and run computations on this graphics card"

// fp16_supported = adapter.features.has("shader-f16") — Checks if the GPU supports fp16 (16-bit floating point) operations in shaders. fp16 has slightly lower precision but is 2-4 times faster. For model inference (which doesn't require high-precision scientific computing), fp16 is the most cost-effective choice.

// Throw error — Manually throwing will be caught by the catch block below, and the error message is sent back to the main thread for display via self.postMessage.

6.4 The Relationship Between TypeScript Compilation and Runtime

A key understanding from the note: "Use ts during development, js after code packaging."

During Development:              After Build (Production):
────────                        ────────────────
App.tsx    ──→ tsc type check    App.js
worker.js  ──→ Vite bundle       worker-a3b8f2c1.js
Type declarations ──→ Guide IDE hints   Type information completely erased

All of TypeScript's type annotations, interfaces, generics...
no longer exist after packaging; at runtime, there is only pure JavaScript.

So @types/webgpu only needs to be in devDependencies — 
it only helps the IDE and compiler during development and doesn't affect the final product.

7. Detailed Explanation of the TypeScript Configuration File

Original Note:

tsconfig.json typescript configuration file, make various configurations according to project needs
types configuration installed type files

7.1 Complete Field-by-Field Analysis of tsconfig.app.json

{
  "compilerOptions": {
    // tsBuildInfoFile: Path for incremental compilation cache info file
    // TypeScript stores the info from the last compilation here, next time only compiles changed files
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",

    // target: Compilation target JS version
    // "es2023" means the compiled output can use all features of ES2023
    // No need to downgrade to ES5/ES6 (because modern browsers all support ES2023)
    "target": "es2023",

    // lib: Tells TS the APIs available in the code's runtime environment
    // "ES2023" → Can use the latest JS built-in objects and methods
    // "DOM"    → Can use browser DOM APIs (document, window, etc.)
    "lib": ["ES2023", "DOM"],

    // module: Module system
    // "esnext" → Use the latest ES Module specification (import/export)
    "module": "esnext",

    // types: Extra type declaration packages to include
    // "vite/client" → Vite-specific types (like import.meta.env)
    // "@webgpu/types" → Type declarations for the WebGPU API
    // This is what the note means by "types configuration installed type files"
    "types": ["vite/client", "@webgpu/types"],

    // allowArbitraryExtensions: Allows importing files with arbitrary extensions
    // e.g., no error when importing non-TS/JS files like .png, .svg
    "allowArbitraryExtensions": true,

    // skipLibCheck: Skips type checking of .d.ts declaration files
    // Speeds up compilation, prevents type issues in third-party libraries from affecting compilation
    "skipLibCheck": true,

    // ====== Bundler Mode Configuration ======

    // moduleResolution: Module resolution strategy
    // "bundler" → Delegates module resolution to the bundler (Vite), TS only does type checking
    "moduleResolution": "bundler",

    // allowImportingTsExtensions: Allows import with .ts/.tsx extensions
    "allowImportingTsExtensions": true,

    // verbatimModuleSyntax: Preserves original import/export syntax
    // Does not transform module syntax (leaves it to the bundler)
    "verbatimModuleSyntax": true,

    // moduleDetection: Module detection
    // "force" → Treats all files as modules
    "moduleDetection": "force",

    // noEmit: Do not generate output files
    // TS only does type checking, does not compile to JS (Vite/esbuild handles compilation)
    "noEmit": true,

    // jsx: JSX syntax transformation method
    // "react-jsx" → React 17+'s new JSX Transform (no need to import React)
    "jsx": "react-jsx",

    // ====== Linting Related ======

    // noUnusedLocals: Error on unused local variables
    "noUnusedLocals": true,

    // noUnusedParameters: Error on unused function parameters
    "noUnusedParameters": true,

    // erasableSyntaxOnly: Only allow erasable TS syntax
    // Prohibits using syntax like enum, namespace that generates runtime code during compilation
    "erasableSyntaxOnly": true,

    // noFallthroughCasesInSwitch: Prohibits switch case fallthrough (missing break)
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

Explanation of a few easily confused configuration items:

Config Item Role Why Set This Way
noEmit: true TS does not output JS files Vite uses esbuild for compilation, much faster than tsc
moduleResolution: "bundler" Module resolution delegated to bundler Keeps TS and Vite consistent on module lookup
erasableSyntaxOnly: true Disables enum/namespace Modern TS recommends only using "type annotations," leaving the rest to JS
jsx: "react-jsx" New JSX Transform No need to manually import React in every file

8. Design Pattern: Singleton Pattern

Original Note:

## Design Patterns
OOP Object-Oriented Programming, summarized 23 patterns for solving specific problems
Data structures, ADT
Design to interfaces, not implementations Design Pattern
### Singleton Pattern
A class is instantiated only once, globally only one instance.
Used to solve the problem of global variables and global state

8.1 What is the Singleton Pattern

The Singleton pattern is one of the most commonly used among the 23 GoF design patterns. The note summarizes its essence in one sentence:

"A class is instantiated only once, globally only one instance."

Understand it with the simplest demo:

<!-- singleton/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Singleton Pattern Open Window</title>
</head>
<body>
  <button id="openBtn">Open New Webpage</button>
  <script>
    class Popup {
      static ins; // Singleton instance, static property, no need to new Popup.ins

      static getInstance() {
        if (!Popup.ins) {          // First call → Create
          Popup.ins = new Popup(); // Subsequent calls → Skip
        }
        return Popup.ins;         // Always return the same instance
      }

      open(url) {
        window.open(url, "_blank");
      }
    }

    const a = Popup.getInstance(); // Singleton pattern, replaces new
    const b = Popup.getInstance();
    console.log(a === b); // true —— The two obtained are exactly the same object

    const openBtn = document.getElementById("openBtn");
    openBtn.addEventListener("click", () => {
      window.open("https://www.baidu.com", "_blank");
      a.open("https://www.baidu.com");
    });
  </script>
</body>
</html>

Code analysis line by line:

static ins; — The static keyword makes ins a static property of the class, belonging to the Popup class itself, not to an instance of Popup. Access is via Popup.ins, no need for new Popup(). It stores that unique instance.

static getInstance() — A static method, also belonging to the class itself. This method is the sole entry point for obtaining the singleton. External code should not use new Popup() to create an instance (although JavaScript currently doesn't have true private constructors to prevent it).

if (!Popup.ins) — This is the key: checks if the instance has already been created.

const a = Popup.getInstance() — Note comment: "Singleton pattern, replaces new". Normally creating an object uses new Popup(), here Popup.getInstance() is used. Superficially both get a Popup object, but new creates a new object each time, getInstance always returns the same one.

console.log(a === b) — Prints true. This confirms that a and b point to the same object in memory.

8.2 Singleton Pattern in the Worker: Lazy Loading + ??=

// worker.js lines 10-27
/**
 * This class uses the Singleton pattern to enable lazy-loading of the pipeline
 */
// pipeline assembly line text generation
// tokenizer large model config file
class TextGenerationPipeline {
  static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
  // Singleton pattern, llm only needs to be initialized once, can be used continuously afterward, good for instantiation performance, singleton management
  static async getInstance(progress_callback = null) {
    // tokenizer
    // AutoTokenizer provided by transform.js 
    // Adapted for DeepSeek-R1-Distill-Qwen-1.5B-ONNX
    // download
    // download progress update
    // 100% from_pretrained ready to use
    this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
      // download progress callback function
      progress_callback,
    });

    return Promise.all([this.tokenizer]);
  }
}

Comment analysis line by line:

// pipeline assembly line text generation — Pipeline is a core concept in the HuggingFace ecosystem, connecting multiple processing steps (tokenization, inference, decoding) into an "assembly line." Our custom TextGenerationPipeline encapsulates the complete flow of "load → tokenize → infer."

// tokenizer large model config file — A complete model load requires three things: Tokenizer (responsible for text ↔ Token), Large Model (Model, neural network weights), Config file (config.json, model architecture parameters).

// Singleton pattern, llm only needs to be initialized once, can be used continuously afterward — This is the core value of the Singleton pattern. A 1.5B parameter LLM loaded into memory occupies about 800MB to several GB, and loading it once takes several minutes. If it were reloaded every time, the user experience would be unacceptable.

// good for instantiation performance, singleton management — Singleton is not optional; it's a performance requirement. Repeatedly creating model instances = repeatedly allocating hundreds of MB of memory + repeatedly parsing the ONNX graph structure, which is extremely wasteful.

// AutoTokenizer provided by transform.js — The "Auto" in AutoTokenizer means it automatically selects the appropriate tokenizer type. Different models use different tokenization algorithms (BPE, WordPiece, SentencePiece, etc.), and AutoTokenizer automatically chooses the correct implementation based on the model's config.json.

// Adapted for DeepSeek-R1-Distill-Qwen-1.5B-ONNXfrom_pretrained downloads and adapts the tokenizer for the specified model. It downloads tokenizer.json (vocabulary) and config.json (configuration), then builds a Tokenizer object capable of correctly encoding/decoding text for that model.

// download — The first step of from_pretrained: checks the IndexedDB cache; if present, reads directly; if not, downloads from the HuggingFace CDN.

// download progress update — The progress_callback parameter: triggers a callback roughly every 16KB of data downloaded, and the Worker forwards this progress info to the main thread via self.postMessage.

// 100% from_pretrained ready to use — After the download reaches 100%, the tokenizer/model is built and ready for use.

// download progress callback function — The specific form of the callback function is passed in during load():

(x) => {
  self.postMessage(x);  // Forward as-is to the main thread
}

8.3 ??= Nullish Coalescing Assignment vs ||= vs =

this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
  progress_callback,
});

This is an operator introduced in ES2021. It is equivalent to:

if (this.tokenizer === null || this.tokenizer === undefined) {
  this.tokenizer = AutoTokenizer.from_pretrained(this.model_id, {
    progress_callback,
  });
}

Precise comparison of the three assignment operators:

Operator Trigger Condition Performance in Example
= Unconditional Re-downloads every call — a performance disaster
` =`
??= Left side is null or undefined Only downloads if "uninitialized" ✅ semantically precise

Why not use ||=? Suppose the tokenizer object has a method that returns 0 or "" (though uncommon), ||= would mistakenly judge it as "uninitialized." ??= only cares about "has it been assigned yet," which is more precise semantics.

8.4 Promise.all Parallel Loading

return Promise.all([this.tokenizer]);

Promise.all takes an array of Promises and returns an array of results once all Promises have completed. In this simplified version, there's only one element, but in a complete implementation, it should be:

return Promise.all([this.tokenizer, this.model]);
Serial Loading (Slower):
Tokenizer ════→ Done
                ↓
              Model ════════════→ Done
Total time: T1 + T2

Parallel Loading (Faster):
Tokenizer ════→ Done
Model ════════════→ Done
Total time: max(T1, T2)  ← Only waits for the slowest one

8.5 load() Function: Passing the Progress Callback to the Singleton

// worker.js lines 51-63
async function load() {
  self.postMessage({
    status: "loading",
    data: "Loading model...",
  });

  const [tokenizer] = await TextGenerationPipeline.getInstance((x) => {
    // We also add a progress callback to the pipeline so that we can
    // track model loading.
    console.log(x, '//////////////');
    self.postMessage(x);
  });
}

Line-by-line analysis:

self.postMessage({ status: "loading", data: "Loading model..." }) — First notifies the main thread "I am starting to load," the main thread sets status to "loading", and the UI switches from the homepage to the progress bar view.

TextGenerationPipeline.getInstance((x) => { ... }) — Calls the singleton's getInstance method and passes in a progress callback function. x is the progress information object passed by the framework on each callback.

console.log(x, '//////////////') — Debug log, observing the raw data of the download progress in the browser console. '//////////////' is a conspicuous separator for easy location among a pile of logs.

self.postMessage(x) — Forwards the received progress information as-is to the main thread. The Worker here only acts as a data relay, doing no processing. All business logic (creating progress bars, updating percentages, removing progress bars) is completed in the main thread's onMessageReceived.

Design Intent: Keep the Worker pure — only responsible for computation and network requests. All UI logic stays in the main thread. This is a clear separation of concerns line.


9. Vite Build Configuration

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    react(),       // ① React Plugin
    tailwindcss()  // ② Tailwind CSS v4 Plugin
  ],
})

The roles of the two plugins:

@vitejs/plugin-react — Enables Vite to support React's JSX syntax and Fast Refresh (not losing component state during hot updates).

@tailwindcss/vite — The Vite plugin for Tailwind CSS v4. The biggest difference between v4 and v3: no longer requires tailwind.config.js, instead uses CSS-native @theme to define design tokens.

Complete Tech Stack Overview:

Technology Role Version
React UI framework, functional components + Hooks 19.x
TypeScript Type safety 6.x
Vite Build tool, lightning-fast HMR 8.x
Tailwind CSS Atomic CSS 4.x
@huggingface/transformers Browser-side model inference 4.x
ONNX Runtime Web Cross-platform model inference engine (indirect dependency)
WebGPU GPU hardware acceleration W3C Standard
marked Markdown → HTML 18.x
DOMPurify XSS security filtering 3.x
better-react-mathjax Math formula rendering 3.x

10. App.tsx Component Architecture Overview

10.1 Constant Definitions Outside the Component

// App.tsx lines 3-11
const IS_WEBGPU_AVAILABLE = !!(navigator as any).gpu;
const STICKY_SCROLL_THRESHOLD = 120;
const EXAMPLES = [
  "Solve the equation x^2 - 3x + 2 = 0",
  "Lily is three times older than her son...",
  "Write python code to compute the nth fibonacci number.",
];

These three constants are written outside the component function because their values never change throughout the application's lifecycle. Writing them inside would recreate them on every re-render (new array, new object reference); writing them outside creates them only once.

The three questions in EXAMPLES intentionally cover DeepSeek-R1's three major capabilities: mathematical reasoning, logical reasoning, and code generation.

10.2 React State Definitions

// App.tsx lines 17-28
// Model loading and progress
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");

// Inputs and outputs
const [messages, setMessages] = useState([]);

10.3 Conditional Rendering Logic

// If WebGPU is not supported → Directly display error page
return IS_WEBGPU_AVAILABLE ? (
  <div>...Entire App...</div>
) : (
  <div>WebGPU is not supported by this browser :(</div>
)
// Homepage (status === null and no messages)
{status === null && messages.length === 0 && (
  // Logo, Title, Intro text, Load model button, Example questions
)}

// Loading (status === "loading")
{status === "loading" && (
  // Progress bar list
)}

// Chat Interface (status === "ready")
{status === "ready" && (
  // Chat component, Input box
)}

// Error Prompt
{error && (
  // Red error box
)}

These four states are mutually exclusive; only one of these UIs will be rendered at any time. This is the core idea of state-driven UI.


11. Complete Data Flow Review

User inputs "Solve the equation x²-3x+2=0" and presses Enter
  │
  ▼
┌─ Main Thread ─────────────────────────────────────────────────────┐
│                                                                    │
│ onEnter() → messages append { role: "user", content: "..." }       │
│                                                                    │
│ useEffect detects messages change                                  │
│   → worker.current.postMessage({ type: "generate",                 │
│                                  data: messages })                 │
│                                                                    │
└──────────────────┬─────────────────────────────────────────────────┘
                   │  postMessage
                   ▼
┌─ Worker Thread ───────────────────────────────────────────────────┐
│                                                                    │
│ case "generate":                                                   │
│   ① tokenizer.apply_chat_template(messages) → token IDs            │
│   ② model._forward(token_ids) → Predict next token                 │
│   ③ tokenizer.decode(new_token) → Text                             │
│   ④ self.postMessage({ status: "update", output: "x" })            │
│   ⑤ Loop ②-④ until max_tokens or EOS token                        │
│                                                                    │
└──────────────────┬─────────────────────────────────────────────────┘
                   │  postMessage
                   ▼
┌─ Main Thread ─────────────────────────────────────────────────────┐
│                                                                    │
│ case "start":  messages append { role: "assistant", content:"" }   │
│ case "update": messages[last].content += output                    │
│                  React re-render → User sees character-by-character output │
│ case "complete": setIsRunning(false) → Input box restored          │
│                                                                    │
│ Rendering Pipeline:                                                │
│   marked.parse(assistant.content) → HTML string                    │
│   DOMPurify.sanitize(html) → Safe HTML                             │
│   <MathJax> wrapper → Math formula rendering                       │
│   React dangerouslySetInnerHTML → Display on page                  │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

This is the complete technical analysis of "running DeepSeek-R1 in the browser using WebGPU." From the HuggingFace model community, to the Transformer.js engine, to the Web Worker multi-threading architecture, to WebGPU hardware acceleration, to the TypeScript type system, and finally to the design philosophy of the Singleton pattern — each layer is analyzed in close conjunction with the code and comments.

I hope this article can help fellow developers who are also exploring browser-side AI. Feel free to discuss and exchange ideas in the comments section!

Comments

Top 2 from juejin.cn, machine-translated. The original thread is authoritative.

用户3928171112

You need to follow each other first to send messages.

用户3928171112

Can you do frontend modifications? Accepting paid work, as long as the price is reasonable, DM me.