Siddhant Deval
Siddhant Deval
frontend5 min read

Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js

Routing every keystroke classification and PII check to cloud LLMs inflates latency and transmits sensitive enterprise data across the public internet. This article runs quantised Small Language Models and embedding models directly in the browser via WebGPU and Transformers.js v3, implementing a hybrid routing strategy that keeps private data on-device and reserves cloud inference for complex reasoning.

Series·Part 21 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js

Every request that reaches an LLM API carries a privacy risk proportional to the sensitivity of the text it contains. A user pasting a compensation spreadsheet into the Workspace chat sends salary data to a third-party API. A query containing a customer's medical record enters a cloud provider's inference pipeline. GDPR, HIPAA, and enterprise data governance policies increasingly require that certain categories of data never leave the user's device.

This article implements a hybrid execution model: quantised SLMs running on WebGPU via Transformers.js handle local PII scrubbing, on-device embeddings, and fast intent classification — keeping sensitive data on the device. Complex reasoning and long-context generation route to cloud LLMs only after sensitive entities are redacted. The routing decision is made by a data-sensitivity classifier that runs entirely in the browser.

Architectural Note

Who this is for: Engineers building AI features for regulated industries (healthcare, finance, legal) or enterprise customers with strict data residency requirements, who need client-side inference as a practical production capability rather than a demo.


1. WebGPU and Transformers.js in 2025

1.1 What Is Possible On-Device

Modern quantised SLMs (4-bit GGUF, INT8 ONNX) run comfortably in the browser on a GPU with 8GB VRAM via WebGPU. On a MacBook M3 Pro:

Task Model Latency VRAM
Sentence embedding Xenova/all-MiniLM-L6-v2 5–15ms 90MB
PII entity recognition Xenova/bert-base-NER 20–40ms 120MB
Intent classification Xenova/distilbert-base-uncased-finetuned-sst-2-english 10–25ms 70MB
Small generation Xenova/Qwen2.5-0.5B-Instruct 8–12 t/s 400MB

2. On-Device PII Scrubbing Pipeline

2.1 NER-Based Entity Detection

TYPESCRIPT
// ✅ On-device PII scrubber using BERT NER via Transformers.js
// file: apps/web/src/lib/ai/PiiScrubber.ts

// Package moved from @xenova/transformers to @huggingface/transformers (v3+)
// Model hub: Xenova/ namespace still valid for existing ONNX exports
import { pipeline, env } from '@huggingface/transformers'

// Use WebGPU when available, fallback to WASM
env.backends.onnx.wasm.numThreads = 4

type NerPipeline = Awaited<ReturnType<typeof pipeline>>

let nerPipeline: NerPipeline | null = null

async function getNerPipeline(): Promise<NerPipeline> {
  if (!nerPipeline) {
    // Detect WebGPU support — Safari <18, Firefox without flags, and older Android
    // WebViews do not support WebGPU. Transformers.js falls back to WASM silently.
    // WASM inference is 10-30× slower — prefer to avoid for real-time PII scrubbing.
    const supportsWebGpu = typeof navigator !== 'undefined' && !!navigator.gpu
    const device = supportsWebGpu ? 'webgpu' : 'wasm'

    nerPipeline = await pipeline(
      'token-classification',
      'Xenova/bert-base-NER',
      { device, dtype: 'q8' },  // INT8 quantised; 'q8' works on both webgpu and wasm
    )
  }
  return nerPipeline
}

export interface ScrubResult {
  scrubbed: string
  redactedEntities: { type: string; original: string; placeholder: string }[]
  sensitivityScore: number  // 0.0 – 1.0
}

const SENSITIVE_ENTITY_TYPES = new Set([
  'PER',   // Person names
  'ORG',   // Organisations (for internal-only docs)
  'MISC',  // Miscellaneous named entities
])

export async function scrubPii(text: string): Promise<ScrubResult> {
  const ner = await getNerPipeline()
  const entities = await ner(text, { ignore_labels: [] })

  let scrubbed = text
  const redactedEntities: ScrubResult['redactedEntities'] = []
  let offset = 0

  // Sort by start position to apply redactions in order
  const sensitive = (entities as any[])
    .filter((e: any) => SENSITIVE_ENTITY_TYPES.has(e.entity_group ?? e.entity?.split('-').pop()))
    .sort((a: any, b: any) => a.start - b.start)

  for (const entity of sensitive) {
    const placeholder = `[${entity.entity_group ?? 'ENTITY'}]`
    redactedEntities.push({
      type: entity.entity_group,
      original: entity.word,
      placeholder,
    })

    // Replace in scrubbed text (offset-adjusted)
    const start = entity.start + offset
    const end = entity.end + offset
    scrubbed = scrubbed.slice(0, start) + placeholder + scrubbed.slice(end)
    offset += placeholder.length - (entity.end - entity.start)
  }

  const sensitivityScore = Math.min(1.0, sensitive.length * 0.15)

  return { scrubbed, redactedEntities, sensitivityScore }
}

3. On-Device Embeddings for Private Documents

TYPESCRIPT
// ✅ On-device embedding for sensitive documents
// file: apps/web/src/lib/ai/LocalEmbedder.ts

import { pipeline, env } from '@xenova/transformers'

let embedder: any = null

async function getEmbedder() {
  if (!embedder) {
    embedder = await pipeline(
      'feature-extraction',
      'Xenova/all-MiniLM-L6-v2',
      { device: 'webgpu', dtype: 'q8' },
    )
  }
  return embedder
}

export async function embedLocally(text: string): Promise<number[]> {
  const model = await getEmbedder()
  const output = await model(text, { pooling: 'mean', normalize: true })
  return Array.from(output.data as Float32Array)
}

// Note: dimensions are 384 (MiniLM) vs 1536 (cloud text-embedding-3-large)
// For private documents: use local 384d embeddings stored in IndexedDB
// For cross-document search in vault: use cloud 1536d embeddings (after scrubbing)

4. Hybrid Routing Layer

TYPESCRIPT
// ✅ Hybrid dispatcher — selects on-device vs cloud based on sensitivity
// file: apps/web/src/lib/ai/HybridDispatcher.ts

import { scrubPii, ScrubResult } from './PiiScrubber'
import { fetchSSE } from '../sseClient'

export type ExecutionMode = 'local' | 'cloud' | 'cloud_scrubbed'

export interface DispatchConfig {
  piiThreshold: number        // Above this → scrub before cloud (default: 0.3)
  cloudRequiredThreshold: number // Above this → always cloud, never local (default: 0.8)
  maxLocalTokens: number      // Tasks longer than this → cloud (default: 500 tokens)
}

const DEFAULT_CONFIG: DispatchConfig = {
  piiThreshold: 0.3,
  cloudRequiredThreshold: 0.8,
  maxLocalTokens: 500,
}

export class HybridDispatcher {
  constructor(private config: DispatchConfig = DEFAULT_CONFIG) {}

  async dispatch(
    userInput: string,
    taskComplexity: 'simple' | 'complex',
  ): Promise<{ mode: ExecutionMode; scrubResult?: ScrubResult; response: string }> {
    const scrubResult = await scrubPii(userInput)
    const tokenEstimate = Math.ceil(userInput.length / 4)

    // Determine routing
    let mode: ExecutionMode
    let inputToSend: string

    if (
      taskComplexity === 'simple' &&
      tokenEstimate <= this.config.maxLocalTokens &&
      scrubResult.sensitivityScore < this.config.piiThreshold
    ) {
      // Low sensitivity, simple task → run locally
      mode = 'local'
      inputToSend = userInput
    } else if (scrubResult.sensitivityScore >= this.config.piiThreshold) {
      // High sensitivity → scrub, then send to cloud
      mode = 'cloud_scrubbed'
      inputToSend = scrubResult.scrubbed
    } else {
      // Complex task, low sensitivity → cloud
      mode = 'cloud'
      inputToSend = userInput
    }

    const response = mode === 'local'
      ? await this.runLocally(inputToSend)
      : await this.runOnCloud(inputToSend)

    return { mode, scrubResult, response }
  }

  private async runLocally(text: string): Promise<string> {
    // Qwen 2.5 0.5B on WebGPU — handles simple summarisation, classification
    const model = await pipeline('text-generation', 'Xenova/Qwen2.5-0.5B-Instruct', {
      device: 'webgpu', dtype: 'q4',
    })
    const result = await model(text, { max_new_tokens: 200 })
    return (result as any)[0].generated_text
  }

  private async runOnCloud(text: string): Promise<string> {
    // Route to gateway SSE — text has already been scrubbed if needed
    const chunks: string[] = []
    for await (const event of fetchSSE('/api/chat/stream', { message: text, history: [] }, new AbortController().signal)) {
      const action = JSON.parse(event.data)
      if (action.type === 'text_reply') chunks.push(action.payload.content)
    }
    return chunks.join('')
  }
}

5. Loading State and Model Download UX

WebGPU model loading requires downloading quantised weights (70MB–400MB). Users must see a meaningful loading state.

TYPESCRIPT
// ✅ Model loading progress with React Suspense-compatible state
// file: apps/web/src/hooks/useLocalModel.ts

import { useState, useEffect } from 'react'
import { env } from '@huggingface/transformers'

export interface ModelLoadState {
  status: 'idle' | 'downloading' | 'loading' | 'ready' | 'error'
  progress: number  // 0–100
  error?: string
}

export function useLocalModel(modelId: string): ModelLoadState {
  const [state, setState] = useState<ModelLoadState>({ status: 'idle', progress: 0 })

  useEffect(() => {
    setState({ status: 'downloading', progress: 0 })

    env.backends.onnx.wasm.numThreads = 4

    pipeline('feature-extraction', modelId, {
      device: 'webgpu',
      dtype: 'q8',
      progress_callback: (progress: { status: string; progress?: number }) => {
        if (progress.status === 'downloading') {
          setState({ status: 'downloading', progress: progress.progress ?? 0 })
        } else if (progress.status === 'loading') {
          setState({ status: 'loading', progress: 100 })
        }
      },
    }).then(() => {
      setState({ status: 'ready', progress: 100 })
    }).catch(error => {
      setState({ status: 'error', progress: 0, error: String(error) })
    })
  }, [modelId])

  return state
}

Summary

Capability Tool VRAM Latency
PII scrubbing Xenova/bert-base-NER (INT8) 120MB 20–40ms
Local embedding Xenova/all-MiniLM-L6-v2 (INT8) 90MB 5–15ms
Simple generation Xenova/Qwen2.5-0.5B (INT4) 400MB 8–12 t/s
Routing decision Rule-based + NER score 0 <5ms

What's Next

Part 22 switches to quality assurance. We build a Ragas evaluation pipeline that automatically scores every RAG response on Faithfulness, Answer Relevance, and Context Recall — integrated into the CI pipeline so RAG quality regressions are blocked before they reach production.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#WebGPU#Transformers.js#Client-Side AI#Edge AI#React#PII#Browser#ONNX
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 21 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
Reading
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace