A browser QR scanner that decodes clean codes at jsQR speed with zero WASM, runs off the main thread, and escalates to an opt-in OpenCV WeChat super-resolution tier only for the codes the fast path misses.
Race mode (opt-in) sends the same frame to both workers at once so you
can see which tier wins on your own hardware. It is a measuring tool, not the
default: normally the tiers cascade, and the WeChat worker never wakes for a
code jsqr already read.
npm i qrcode-decode-ultra # core — zero WASM, framework-agnostic
npm i qrcode-decode-ultra-react # optional: useScanner() + <Scanner/>
npm i qrcode-decode-ultra-wechat # optional: strong tier, ~2.5 MB gzippedBrowser QR scanning makes you pick up front. jsQR is ~45 KB gzipped of pure JS and reads a clean, well-lit code instantly — but it misses the codes people actually photograph: small, far away, motion-blurred, badly lit. The engines that do read those are megabytes of WASM, downloaded by every visitor including the majority scanning a crisp code at arm's length. And most scanners run the decode loop on the main thread, so the UI stutters while the camera is live.
You can't pick correctly up front, because which tier a frame needs is only knowable after the cheap one has failed. So don't pick — cascade.
Engines run in a fixed order and the first one to decode anything wins: its results return immediately and no later tier is consulted. Cost climbs at each step, so the common case never pays for the hard case.
flowchart TD
F(["frame — Blob · video · canvas · ImageBitmap"])
N["`1 · native
platform BarcodeDetector
0 KB · every code in frame`"]
J["`2 · jsQR
pure JavaScript
~45 KB gzipped · no WASM`"]
W["`3 · WeChat
OpenCV CNN + super-resolution
~2.5 MB gzipped`"]
R(["ScanResult[]"])
subgraph fast ["fast worker — default, zero WASM"]
N
J
end
subgraph strong ["strong worker — opt-in, separate package"]
W
end
F --> N
N -- hit --> R
N -- miss --> J
J -- hit --> R
J -. miss .-> W
W --> R
- The default install is zero-WASM.
native + jsQRonly; the WeChat tier is a package you install separately, so its payload is never in a default bundle. - Nothing heavy touches the main thread by default. Both tiers run in
workers; the main thread only rasterizes the frame and routes it. Measured in
headless Chromium on an escalated decode: on the main thread, a ~290 ms
blocking task and 17 dropped animation frames; in workers, no long task and no
dropped frames. (
worker: false, or noWorker/OffscreenCanvas, falls back to in-process — seeDESIGN.md.) - Native is probed, not trusted.
'BarcodeDetector' in selfisn't enough — the constructor can exist whiledetect()silently returns[], and a ponyfill can shadow it — so the engine decodes a baked-in synthetic QR and checks for[native code]before native may win. Where it is real (Android Chrome, Chromium on macOS/ChromeOS) it carries no payload at all — 0 KB against jsQR's ~45 KB gzipped — because it is not a reimplementation: Chromium's macOS backend calls Apple's Vision framework, Android's rides ML Kit. It is also slightly stronger than the jsQR tier behind it, though less than the ordering suggests — across the corpus below it reads exactly one more fixture (adamagedcode); every other category ties. Cheaper here means payload, not latency: in headless runs an end-to-end native scan measured slower than jsQR's, and headless wall-clock is too noisy to conclude anything from — run the device report to see which tier actually wins on your hardware. Not the strongest tier overall — seedownscale_32below. - The escalation warms itself. A live frame whose smaller dimension drops
below
lowResPreloadPx(default 480) starts the WeChat load in the background, so it is in flight — usually done — before the frame that needs it. - Backpressure, not a queue. One in-flight decode at a time; a frame arriving mid-decode is dropped, never queued, so the scanner never works on stale video.
- Or race the two workers.
race: truesends each frame to both at once and takes the first real decode, so a hard code costs the fastest tier's time rather than the sum of the tiers ahead of it. Opt-in: it spends a ~250 ms WeChat decode on every frame, and needs both workers — without the WeChat worker registered the scanner silently keeps the cascade.onRacereports each lane's time per frame, which is how you learn which tier wins on a given device.
For the transport split, worker protocol, and Engine contract, see
DESIGN.md.
import { createScanner } from "qrcode-decode-ultra";
const scanner = createScanner();
// Still image — every code the winning engine found in the frame.
const results = await scanner.scanImage(fileOrBlob);
for (const r of results) console.log(r.value, "via", r.engine);Live camera — onResult is the primary code, onResults is all of them:
const controller = scanner.scanVideo(videoEl, {
onResult: (r) => console.log("primary:", r.value),
onResults: (rs) => console.log(`${rs.length} code(s) this frame`),
});
await controller.start();React:
import { Scanner } from "qrcode-decode-ultra-react";
<Scanner facingMode="environment" onResult={(r) => console.log(r.value)} />;Full API: packages/core/README.md.
QR only. No 1D barcodes, no DataMatrix / Aztec / PDF417. That focus is what makes the two-tier design tractable; if you need general barcodes, use a ZXing-based library.
Codes per frame depends on which tier won, because tiers are never unioned.
native returns every code in the frame in one free pass. jsqr and wechat
return one unless you raise maxCodesPerFrame, which buys the extra codes with
extra decode passes (~2.1s for ten codes on jsqr, ~2.6s on wechat) — so it is
a still-image feature and defaults to off. Treat onResults as "everything the
winning engine saw", not a completeness guarantee. Details:
packages/core/README.md.
| Package | What |
|---|---|
qrcode-decode-ultra |
Framework-agnostic scanner: cascade, worker, still-image + camera APIs. Zero WASM. |
qrcode-decode-ultra-react |
useScanner() hook + <Scanner/> component. SSR-safe, owns the camera. |
qrcode-decode-ultra-wechat |
Opt-in OpenCV WeChat tier for low-res / hard codes. Installed separately. |
Every engine receives the same ImageData, decoded once, and a wrong decode
counts as a false positive, not a benign miss — so detection rate can't be
farmed with garbage. Reproduce with pnpm build && pnpm benchmark (the harness
imports the built package): it regenerates the fixtures, re-runs, and rewrites the
table below.
Benchmark: 5 images per category, Node runner (Darwin 25.5.0/x64, node v24.17.0), generated 2026-07-31T03:40:17.923Z.
Detection rate by category (higher is better):
| Engine | clean | rotated | blur | low_light | inverted | downscale_32 | damaged | torture | Overall |
|---|---|---|---|---|---|---|---|---|---|
| qrcode-decode-ultra-fast | 100% | 100% | 100% | 100% | 100% | 0% | 80% | 40% | 78% |
| qrcode-decode-ultra-max | 100% | 100% | 100% | 100% | 100% | 60% | 100% | 80% | 93% |
| jsqr | 100% | 100% | 100% | 100% | 100% | 0% | 80% | 40% | 78% |
| native | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
Speed & cold start:
| Engine | cold-start (ms) | median decode (ms) | overall detection |
|---|---|---|---|
| qrcode-decode-ultra-fast | 1 | 8.5 | 78% |
| qrcode-decode-ultra-max | 747 | 14.8 | 93% |
| jsqr | 0 | 10.7 | 78% |
| native | n/a | n/a | n/a — no platform BarcodeDetector in Node — native needs a real browser |
Native
BarcodeDetectoris reportedn/ahere because there is no platform backend off-browser; it is not a loss. The honest comparison the cascade lives in is the WASM/JS one above. The WeChat tier's value is its per-category win on the hard columns (downscale / damaged / torture) weighed against its cold-start cost — the fast path already saturates clean, rotated, blur, low-light and inverted.
Read the per-category row, not the aggregate — the cold-start column beside it is what the hard columns cost.
The Node runner above cannot see two things: whether the platform
BarcodeDetector actually works, and what a decode costs the main thread.
Both need a browser, so there is a second harness —
pnpm build && pnpm benchmark:browser (Playwright + headless Chromium).
Workers do not make decoding faster; the transport adds a transfer and a round trip. What they buy is main-thread availability, so blocking is the metric.
Browser benchmark: chromium 151.0.7922.34, Darwin 25.5.0/x64, generated 2026-07-31T02:44:07.523Z.
Main-thread cost of an escalated decode — a downscale_32 fixture, the case the fast path genuinely cannot read, so the WeChat tier runs. Figures span 5 consecutive decodes with the model already loaded.
| Transport | longest task | total blocking | longest frame gap | frames dropped |
|---|---|---|---|---|
| main thread (worker: false) | 288 ms | 516 ms | 299 ms | 17 |
| workers (default) | 0.0 ms | 0.0 ms | 17.8 ms | 0 |
A task over 50ms is what the browser itself calls blocking; over ~100ms a tap feels unresponsive.
frames droppedis what a 60fps camera preview loses.
Per-frame cost at 720p — a clean code in a 1280x720 frame read by the fast path, no escalation. The gap between the two rows is what the worker transport costs: one bitmap transfer plus a round trip. Absolute wall-clock is inflated by headless software rasterisation, so read the difference, not the magnitude. Both rows report a 0.0 ms longest task because a fast-path decode is split across awaits, which longtask cannot see — blocking is the escalated-decode table above, not this one.
| Transport | per frame | longest task | frames dropped |
|---|---|---|---|
| main thread (worker: false) | 146 ms | 0.0 ms | 0 |
| workers (default) | 167 ms | 0.0 ms | 0 |
Native BarcodeDetector on this platform: available
Detection rate by category, in-browser (the fast row now includes native):
| Preset | clean | rotated | blur | low_light | inverted | downscale_32 | damaged | torture | Overall |
|---|---|---|---|---|---|---|---|---|---|
| fast | 100% | 100% | 100% | 100% | 100% | 0% | 100% | 40% | 80% |
| max-accuracy | 100% | 100% | 100% | 100% | 100% | 60% | 100% | 80% | 93% |
Engines that won at least one frame here: native, wechat.
Try it on your own device: live demo · device report. The report replays the fixtures below in your browser and hands you the results as pasteable markdown.
The floor is high: jsqr is pure JavaScript, so decoding works anywhere the library loads. What
varies is the free tier — native needs a platform BarcodeDetector, which WebKit has never
shipped, so on iOS jsqr leads and wechat covers the hard codes.
| Grade | Meaning |
|---|---|
full |
every tier available, all off the main thread |
strong |
no platform detector — jsQR leads, WeChat covers hard codes (where iOS lands) |
basic |
fast path only; small or damaged codes will be missed |
degraded |
no worker transport, so decoding blocks the UI |
blocked |
no engine can decode here |
Compatibility matrix: Darwin 25.5.0 x64, Playwright, generated 2026-08-14T02:34:53.774Z.
| Browser engine | Grade | native |
wechat |
fast | max-accuracy | Leading tier (fast) |
|---|---|---|---|---|---|---|
| Chromium 151 | full |
available | available | 80% | 93% | native 242.7 ms |
| Firefox 153 | strong |
no-api |
available | 78% | 93% | jsqr 8.0 ms |
| WebKit 26 | strong |
no-api |
available | 78% | 93% | jsqr 5.0 ms |
nativeis a property of the engine and the platform: Chromium calls Apple's Vision framework on macOS and ML Kit on Android, but has no Shape Detection backend on Linux or Windows. These rows are macOS. WebKit here is Playwright's build, not Safari on an iPhone — the API surface matches, the hardware does not, so read capability from this table and never performance. Real-device numbers come from the device report.
The matrix above is browser engines on one machine, so it answers capability and never
performance. Hardware rows can only come from real devices — Android's native rides ML Kit
rather than Apple's Vision framework, and no headless runner can say what a WeChat decode costs a
mid-range phone. If you run the device report,
pressing Report this device opens a prefilled issue and adds a row here.
No device reports submitted yet — run the device report on your phone and press Report this device.
pnpm install
pnpm build # build all packages via tsup
pnpm test # vitest
pnpm typecheck # tsc --noEmit across the workspace
pnpm demo # Vite dev server: webcam + still image
pnpm benchmark # regenerate fixtures, run, rewrite the table abovepnpm typecheck and pnpm test are the gates and CI runs both; there is no
linter or formatter. See CLAUDE.md for the fuller command list.
MIT for QRCode Decode Ultra's own code. The engines it builds on — jsQR, and the
OpenCV WeChat model behind qrcode-decode-ultra-wechat — are Apache-2.0 and
remain so; their notices are preserved, not relicensed. See NOTICE
and THIRD-PARTY-LICENSES.md.
