Hosting: a static site on GitHub Pages
The whole showcase is static files β HTML, CSS, JS, JSON, and a service worker β served
from GitHub Pages under a project path. There is no application server: the catalogue is a
models.json the page fetches, and each model lives at its own stable route
under models/<slug>/. Weights are pulled at runtime from the Hugging
Face CDN (cross-origin) and cached on-device; nothing round-trips through a backend.
- origin
https://paulkinlan.github.io(a*.github.ioproject site)- base path
/web-ai-showcase/β absolute in-app URLs are written against it- app shell
- cached by
sw.jsfor offline; model blobs are not re-stored (see caching) - weights host
huggingface.co/.../resolve/β¦β Xet CDN, fetched cross-origin with CORS
Static hosting is the point, not a limitation to route around: it's free, it's CDN-backed, it has no secrets to leak, and it forces the honest constraint that all intelligence ships to the client. The trade-offs it does impose β no custom response headers, best-effort storage β are called out explicitly below.
Worker topology: dedicated module workers, a typed protocol, transferables
Inference never runs on the main thread. Each model page owns a dedicated module
worker and talks to it over a small, typed message protocol. Model classes and
the ONNX runtime are import()-ed inside the worker, so parsing and
executing multi-hundred-megabyte graphs can't jank the UI thread.
// front-end helper (e.g. models/yolos-detection/yolos.js, models/sam2-segmentation/sam2.js)
this.worker = new Worker(WORKER_URL, { type: "module" });
this.worker.addEventListener("message", (e) => this._onMessage(e.data));
this.worker.addEventListener("error", (e) => showOnPage(e.message)); // errors surface on the page
The message contract is a small tagged union β every message is { type, id, β¦ }.
type names the operation; id correlates each response with the
request that triggered it, so a stale reply from a superseded request can be dropped
instead of racing the UI. That correlation id is the cancellation and backpressure
mechanism: the worker processes messages serially (natural backpressure), the UI ignores
any result whose id is no longer current, and a hard cancel is a
worker.terminate() + fresh worker. Weight caching is versioned separately, by
the model's Hugging Face repo revision (commit sha) β see caching.
// worker.js β the dispatch half of the protocol (from models/sam2-segmentation/worker.js)
self.addEventListener("message", async (e) => {
const { type } = e.data;
try {
if (type === "load") await ensureLoaded();
else if (type === "embed") await embed(e.data.id, e.data.image);
else if (type === "segment") await segment(e.data.id, e.data.points, e.data.box);
} catch (err) {
post({ type: "error", id: e.data?.id, message: String(err?.message ?? err) });
}
});
// worker β main messages: { type: "progress" | "ready" | "embedded" | "result" | "error", id, β¦ }
function post(msg, transfer) {
self.postMessage(msg, transfer || []); // Transferables move buffers zero-copy
}
The per-demo pattern above is now formalised as reusable canonical modules that new demos
and retrofit waves build on:
lib/worker-protocol.js (a typed, versioned protocol with request ids,
transferables both ways, AbortSignal cancellation, stale-response suppression,
bounded-queue backpressure, worker lifecycle states, and deterministic dispose + object-URL
cleanup);
lib/model-download.js (the resumable HTTP-Range downloader described below);
lib/capture-ux.js (upload / camera / short webcam video / mic, user-initiated,
with full permission and cleanup states); and
lib/media-pipeline.js (OffscreenCanvas/ImageBitmap and AudioWorklet
preprocessing off the main thread, with the non-isolated fallback that is the default on
GitHub Pages).
Large binary results are transferred, not copied. A segmentation worker
hands its mask planes back as detached ArrayBuffers in the transfer list, so a
multi-megapixel mask crosses the thread boundary with no serialization cost:
post(
{ type: "result", id, width, height, masks: planes, scores, ms },
planes.map((p) => p.buffer), // transfer list β ownership moves to the main thread
);
{ type: "module" } lets the worker
use import for the Transformers.js / ONNX-Runtime-Web build (and lets one
model pin a newer library version locally without touching shared code). Everything the
model needs is inside the worker; the page keeps only the thin control UI.
Data flow: main β worker β model
The main thread is a controller and a renderer; the worker is where the model lives. Input
crosses in (an image as a data URL / ImageBitmap, or text), inference runs in
the worker, and results β tokens, boxes, masks, embeddings β cross back out, with progress
streamed the whole time so the UI is never a frozen spinner.
Loading is mediated by the shared, accessible loader in
lib/model-loader.js (backed by lib/model-cache.js): a valid,
current on-device model auto-initialises β returning visitors never click
"Load" β while a genuinely absent model, a partially-evicted one, or a newer available
revision each get an honest, labelled action. Progress is announced through a
role="status" aria-live region, and a failed load offers Retry, never a faked
result.
Caching & the resumable-download lifecycle
Model weights are the expensive part β tens to hundreds of megabytes β so cache ownership
is deliberate. Transformers.js keeps its own Cache Storage bucket
(transformers-cache) and writes every model file there, including fetches the
service worker never sees (first visit before control, hard reloads). The service worker
therefore serves model files by matching across all caches and never stores a
second copy β double-storing gigabyte blobs is exactly what pushes an origin into quota
eviction.
// sw.js β serve model blobs cache-first across ALL caches; never double-store
if (MODEL_HOSTS.includes(url.hostname)) {
e.respondWith(caches.match(req).then((hit) => hit || fetch(req)));
return; // the library owns transformers-cache; we only cache the small app shell
}
Versioning is by Hugging Face repo revision: lib/model-cache.js records which
files a successful load cached plus the repo's commit sha, then on revisit compares
recorded files against what's still cached (missing β evicted β partial) and the
recorded revision against the live one (newer β optional update). Offline, the
cached copy is treated as current so offline use keeps working.
Resumable downloads β validated for this exact deployment
For the large models, restarting a failed download from zero is painful, so the reference
design is a real HTTP-Range resume. The download-resume research confirmed
every prerequisite holds cross-origin from a *.github.io page against the HF
CDN, on files from 23 MB to 786 MB:
-
Always fetch the
β¦/resolve/main/β¦URL. It 302-redirects to the Xet CDN and the browser re-sends yourRangeheader through the redirect; the final response is206 Partial Content,type:"cors"(not opaque), so JS can read the body,Content-Range, and the strongETag. -
The
ETagis strong (a content hash), soIf-Range: <etag>conditional resume works. A changed file answers200(full body) instead of206β the built-in "restart cleanly" signal. -
Size and integrity are CORS-exposed:
X-Linked-Size(total bytes) andX-Linked-ETag(the git-LFS sha256 of the whole file), so the assembled blob can be integrity-checked, not just length-checked. -
No
Content-Encodingon the binary payload β byte offsets map 1:1 to file bytes, so offset math is safe.
// Resumable model download β the researched lifecycle (reference implementation).
async function downloadResumable(resolveUrl, store /* IndexedDB */) {
let { receivedBytes = 0, etag, total } = (await store.get(resolveUrl)) ?? {};
while (total == null || receivedBytes < total) {
const res = await fetch(resolveUrl, {
headers: { Range: `bytes=${receivedBytes}-`, ...(etag ? { "If-Range": etag } : {}) },
});
if (res.status === 200) { receivedBytes = 0; } // file changed β honest restart
total ??= Number(res.headers.get("content-range")?.split("/")[1] ?? res.headers.get("x-linked-size"));
etag = res.headers.get("etag") ?? etag;
for await (const chunk of res.body) { // append + persist the partial
await store.appendChunk(resolveUrl, chunk, (receivedBytes += chunk.byteLength));
}
}
// Completion gate: verify sha256(blob) === X-Linked-ETag before marking "ready".
if (await sha256Hex(await store.assemble(resolveUrl)) !== expectedSha) throw new Error("integrity mismatch β discard + restart");
}
*.github.io origin gets best-effort
storage: IndexedDB / Cache Storage partials can be evicted between sessions unless
navigator.storage.persist() is granted (not guaranteed here). So resume must
re-validate the partial with If-Range and be ready to fall back to a clean
restart, and it must verify the sha256 on completion before trusting the file. The Cache
API alone does not make a stored 206 resumable β you own the
offset/validator bookkeeping in IndexedDB. Transformers.js today re-downloads whole files
via the Cache API on failure; the Range/If-Range/sha256 lifecycle above is the researched
upgrade path, not a claim that the library already resumes.
Isolation-header limits on GitHub Pages
A static host can't set arbitrary response headers, and GitHub Pages won't send
Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy. Those
two headers are the switch for cross-origin isolation, and without them
self.crossOriginIsolated is false.
// On GitHub Pages this is false β no COOP/COEP means no cross-origin isolation.
if (!self.crossOriginIsolated) {
// SharedArrayBuffer is unavailable / non-shareable β multi-threaded WASM can't spin up a thread pool.
// The non-isolated path (single-thread WASM, or WebGPU) is the DEFAULT here, by design.
}
The practical consequence: multi-threaded WebAssembly (ONNX Runtime Web's
threaded backend) relies on SharedArrayBuffer, which requires cross-origin
isolation. On this deployment that isn't available, so the non-isolated fallback is the
default: models run on single-threaded WASM or, when a real adapter
exists, on WebGPU β which is the fast path anyway and needs no SAB. Every
page probes for an actual WebGPU adapter (mere "gpu" in navigator is not
enough) and falls back to WASM honestly, labelling WebGPU-only models as such rather than
faking a result.
COOP: same-origin + COEP: require-corp
(or credentialless) turns on isolation and unlocks threaded WASM β at the cost
of every cross-origin subresource needing CORP/CORS. On GitHub Pages that door is closed,
so the architecture is built to be fast without it.
Audio, video, and image pipelines
Media decode and pixel work also belong off the main thread, and the platform provides the transferable primitives to keep them there.
- images
-
Decode to an
ImageBitmap(transferable) and hand it straight into the worker; render results β overlays, masks β onto anOffscreenCanvasso drawing doesn't compete with the UI. Masks come back as transferredArrayBuffers and paint viaputImageData. - audio
-
Capture and resample on an
AudioWorklet(its own real-time thread), buffer the Float32 PCM the model expects (e.g. 16 kHz mono for Whisper), and post it to the inference worker β never block the audio render quantum with model work. - video
-
Pull frames as
ImageBitmaps (orVideoFrames) and run the per-frame model in the worker, reusing cached per-image state where the model allows it (e.g. SAM's image embedding is computed once, then every prompt is cheap).
The unifying rule: get pixels and samples into a transferable as early as possible, do the heavy work in a worker, and transfer results back β copies and main-thread decodes are the things that jank.
Security & privacy: everything stays on your device
Because there is no backend, there is nowhere for your data to go. Images you drop, audio you record, and text you type are processed entirely in your browser; they are never uploaded, and there are no API keys because there is no API to call. The only network traffic is fetching the model weights from the Hugging Face CDN (and the library JS from jsDelivr) the first time β after which the model is cached and the demo works offline.
- No uploads, no telemetry of your inputs β inference is local, full stop.
-
Only canonical weights are loaded β from the model's official HF repo
(or MLC / MediaPipe), never arbitrary remote code;
env.allowLocalModelsis off so the library can't be pointed at a surprise path. -
Secure context β the site is HTTPS, which WebGPU, service workers, and
SharedArrayBuffer(where available) all require anyway. -
Integrity on completion β the resumable-download design verifies the
assembled weights' sha256 against the server's
X-Linked-ETagbefore a model is marked ready.
Performance budgets
Responsiveness is a budget, not an afterthought. Two numbers frame everything: at 60 fps a frame is ~16 ms, so main-thread work should stay under roughly 8 ms per frame to leave the browser headroom for style, layout, paint, and input; and any single task over 50 ms is a "long task" that directly hurts Interaction to Next Paint (INP). The whole architecture exists to keep the main thread inside those budgets.
| Budget | Target | How the architecture holds it |
|---|---|---|
| Main-thread frame slice | β€ ~8 ms | Model execution is in a worker; the main thread only routes messages and paints. |
| Long-task ceiling (INP) | < 50 ms per task | Any main-thread post-processing loop yields with scheduler.yield(). |
| Off-main-thread by default | 100% of inference | Dedicated module worker per page; transferables for large buffers. |
| Offscreen render cost | near-zero when hidden | content-visibility:auto + contain-intrinsic-size on the catalogue grid. |
When post-processing genuinely has to touch the main thread β decoding a mask into pixels,
building thousands of DOM nodes β it's sliced so it can't become a long task. Yield on a
~50 ms deadline, with a setTimeout fallback where
scheduler.yield() isn't supported (it's unavailable in Safari):
let deadline = performance.now() + 50; // 50 ms = the long-task boundary
for (const item of items) {
processItem(item);
if (performance.now() >= deadline) {
if ("scheduler" in globalThis && "yield" in scheduler) await scheduler.yield();
else await new Promise((r) => setTimeout(r, 0)); // fallback: Safari has no scheduler.yield
deadline = performance.now() + 50;
}
}
Budgets and techniques follow the project's modern-web-guidance references:
performance, break-up-long-tasks,
defer-rendering-heavy-content, and deliver-optimized-decorative-images.