Skip to content

Repository files navigation

QRCode Decode Ultra

ci license

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.

A QR code on a phone screen, read from a live webcam. A meter beside the video compares the fast worker against the WeChat worker frame by frame, showing which one decoded first and how long each took.

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 gzipped

The problem

Browser 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.

How it works

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
Loading
  • The default install is zero-WASM. native + jsQR only; 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 no Worker/OffscreenCanvas, falls back to in-process — see DESIGN.md.)
  • Native is probed, not trusted. 'BarcodeDetector' in self isn't enough — the constructor can exist while detect() 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 (a damaged code); 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 — see downscale_32 below.
  • 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: true sends 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. onRace reports 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.

Quick start

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.

Scope

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.

Packages

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.

Benchmark

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 BarcodeDetector is reported n/a here 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.

In a real browser

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 dropped is 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.

Compatibility

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

native is 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.

Real devices

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.

Development

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 above

pnpm 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.

License

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.

About

Fast browser QR scanner: native → jsQR zero-WASM fast path in a Web Worker, with an opt-in OpenCV WeChat tier for hard codes.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages