An in-page QR scanner sounds like a thin wrapper around a camera API. The first version often is: request a camera, hand frames to BarcodeDetector, and continue when it returns a QR value.
That design has an awkward failure mode. The camera can work perfectly while QR detection is unavailable. A browser may expose getUserMedia() but not BarcodeDetector, so capability detection turns into a message telling the user to leave the app and find another scanner.
I ran into that boundary while maintaining a browser-to-browser handoff flow. The replacement uses the ordinary camera and canvas APIs plus a pinned, self-hosted copy of jsQR. It does not upload camera frames, fetch a decoder from a CDN, or depend on browser-native barcode detection.
Disclosure: this article was prepared with AI editorial assistance from implementation and test notes. The design and limits below were checked against the deployed client and its automated browser and Android-emulator evidence.
Separate camera support from decoder support
The useful capability test is not “does this browser have a QR API?” It is two smaller questions:
- Can the page obtain camera video after a user action?
- Can code loaded by the application decode pixels from a canvas?
That produces a pipeline with replaceable parts:
user action
-> getUserMedia()
-> video element
-> bounded canvas frame
-> local QR decoder
-> application-specific validation
-> stop camera
-> act on the result
The decoder becomes an application dependency instead of a browser feature. I use jsQR, vendored at a reviewed version under its Apache-2.0 license.
Vendoring matters for more than availability. A camera reader handles data that users reasonably expect to remain on the device. Loading executable code from a third-party runtime origin would create an unnecessary DNS/TLS request and an avoidable supply-chain boundary. The client therefore lazy-loads one versioned same-origin script only when scanning starts.
let decoderPromise;
function loadDecoder(version) {
if (typeof window.jsQR === "function") {
return Promise.resolve(window.jsQR);
}
if (!decoderPromise) {
decoderPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = `/js/view/jsqr.js?v=${encodeURIComponent(version)}`;
script.async = true;
script.onload = () =>
typeof window.jsQR === "function"
? resolve(window.jsQR)
: reject(new Error("QR decoder missing"));
script.onerror = () => reject(new Error("QR decoder failed to load"));
document.head.appendChild(script);
}).catch(error => {
decoderPromise = undefined; // allow a later retry
throw error;
});
}
return decoderPromise;
}
The production loader also has a timeout and removes the temporary script element and event handlers. A failed load resets the cached promise so one network or cache failure does not permanently disable the reader until the tab is closed.
Bound the work before decoding
A phone may provide a 4K camera stream. Allocating and decoding every full-resolution frame is unnecessary for an ordinary QR code and can turn a small feature into a memory and battery problem.
The reader I use has two simple bounds:
- the canvas's longest edge is at most 640 pixels;
- decoding is attempted no more than once every 250 milliseconds.
It still schedules through requestAnimationFrame, but it skips work when the document is hidden, the video is not ready, or the interval has not elapsed.
const MAX_EDGE = 640;
const DETECT_EVERY_MS = 250;
let lastDetectionAt = 0;
function scanFrame(timestamp) {
frameRequest = requestAnimationFrame(scanFrame);
if (
document.hidden ||
video.readyState < 2 ||
!video.videoWidth ||
timestamp - lastDetectionAt < DETECT_EVERY_MS
) {
return;
}
lastDetectionAt = timestamp;
const scale = Math.min(
1,
MAX_EDGE / Math.max(video.videoWidth, video.videoHeight)
);
const width = Math.max(1, Math.round(video.videoWidth * scale));
const height = Math.max(1, Math.round(video.videoHeight * scale));
canvas.width = width;
canvas.height = height;
context.drawImage(video, 0, 0, width, height);
const image = context.getImageData(0, 0, width, height);
const result = decode(image.data, width, height, {
inversionAttempts: "attemptBoth"
});
if (result?.data) handleDecodedValue(result.data);
}
In production, the canvas dimensions are changed only when necessary. That avoids reallocating the backing pixels on every detection attempt.
These numbers are product choices rather than universal constants. Inventory scanning may need a different balance. The important part is to define a memory ceiling and a scan-rate ceiling instead of accepting whatever the camera supplies.
Treat cancellation as a concurrent operation
Camera permission, video playback, and decoder loading are all asynchronous. The user can cancel while any one of them is pending. The page can also become hidden or connect through another path before the promise resolves.
A Boolean active flag alone is easy to get wrong because late work from an earlier start can observe the flag after a newer run has set it back to true. A monotonically increasing run token makes ownership explicit.
let scannerRun = 0;
let stream;
let frameRequest;
let canvas;
function stopScanner() {
scannerRun += 1; // invalidate all pending work
if (frameRequest !== undefined) {
cancelAnimationFrame(frameRequest);
frameRequest = undefined;
}
stream?.getTracks().forEach(track => track.stop());
stream = undefined;
if (canvas) {
canvas.width = 0;
canvas.height = 0;
canvas = undefined;
}
video.srcObject = null;
}
async function startScanner() {
stopScanner();
const run = scannerRun;
const newStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: "environment" } }
});
if (run !== scannerRun) {
newStream.getTracks().forEach(track => track.stop());
return;
}
stream = newStream;
video.srcObject = stream;
await video.play();
if (run !== scannerRun) return;
const decode = await loadDecoder(CLIENT_VERSION);
if (run !== scannerRun) return;
// Create the canvas and begin the bounded frame loop here.
}
Cleanup should run after success and explicit cancellation, but also on pagehide, when the document becomes hidden, and when the application reaches a state that no longer needs scanning. Releasing the canvas backing store matters on low-memory devices; hiding the <video> element is not resource cleanup.
A decoded QR value is still untrusted input
Decoding succeeds before application validation begins. A general QR reader should show the value and ask before opening it. A scanner for a controlled pairing flow can be more direct only if it accepts a narrowly defined URL shape.
For example:
function parsePairingQr(rawValue) {
try {
const candidate = new URL(String(rawValue));
if (!ALLOWED_ORIGINS.has(candidate.origin)) return null;
if (candidate.protocol !== "https:") return null;
if (candidate.pathname !== "/") return null;
if (candidate.port) return null;
if (candidate.username || candidate.password) return null;
const pairingId = candidate.searchParams.get("i");
const secret = parseExpectedFragment(candidate.hash);
if (!pairingId || !secret) return null;
return { candidate, pairingId, secret };
} catch {
return null;
}
}
The actual policy should also reject lookalike hostnames, unrelated paths, malformed fragments, and any field the application does not understand. Do not validate an origin with string prefixes or includes(). Parse the URL, compare exact origins or carefully controlled hostnames, then reconstruct the destination from validated fields rather than navigating to the untouched input.
This is also the point to preserve local work. In a handoff UI, scanning a valid pairing link may navigate the tab. Saving the current draft under the validated new pairing identifier before navigation prevents a successful scan from becoming a data-loss event.
Test pixels, video, policy, and lifecycle separately
Mocking the decoder is useful for UI error states, but it is not enough to prove the reader. The test stack for this implementation has several layers:
- focused JavaScript tests for missing camera APIs, denied permission, failed decoder loading, retry, invalid QR values, cancellation races, throttling, frame bounds, and cleanup;
- real Chromium and Firefox decoding of plain, small, rotated, quarter-turn, upside-down, skewed, inverted, low-contrast, and deterministic-noise QR images, plus a blank negative;
- a canvas-backed
MediaStreamtest that exercises the real<video>→ canvas → jsQR path, then connects two browser sessions and exchanges encrypted text in both directions; - Android API-28 emulator runs through WebView 66 and GeckoView 156, including real Android/browser permission UI, camera-fed decoding, pairing, data exchange, and camera release.
The browser test replaces only physical camera capture. It still uses the shipped generator, decoder, canvas path, lifecycle code, and network flow. Network assertions require exactly one versioned same-origin decoder request and no foreign runtime resource.
There is an important limit to record rather than hide: a canvas video stream and a virtual Android camera do not prove autofocus, exposure, low-light behavior, or vendor camera quirks. Those remain physical-handset checks.
Failure states are part of the feature
“Scanner failed” is not one condition. The interface should distinguish at least:
- the camera API is missing;
- camera access was denied or the camera could not open;
- the local decoder could not load or start;
- a readable QR code is not valid for this application.
Every state should leave a non-camera path available, such as pasting or opening the pairing link. A browser capability gap should reduce convenience, not create a dead end.
What this architecture buys
Replacing BarcodeDetector was not about writing a QR algorithm. It was about making the browser boundary explicit:
- camera access remains permission-gated and user-initiated;
- decoding has no browser-specific barcode dependency;
- frames stay inside the page;
- runtime code comes from the application's own origin;
- CPU and memory work have deliberate ceilings;
- decoded content crosses a strict application policy;
- cancellation and cleanup remain correct while promises are pending;
- tests state exactly which hardware behavior they do not cover.
The deployed example is the pairing scanner in IcyZip's implementation. The same structure applies to login links, device setup, event check-in, and other web flows where the application knows exactly what a valid QR value should contain.
Top comments (0)