# A browser QR scanner without BarcodeDetector or a CDN

> Source: <https://dev.to/icyzip/a-browser-qr-scanner-without-barcodedetector-or-a-cdn-3joc>
> Published: 2026-10-04 00:51:59+00:00

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.

The useful capability test is not “does this browser have a QR API?” It is two smaller questions:

That produces a pipeline with replaceable parts:

``` php
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](https://github.com/cozmo/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.

``` js
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.

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:

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.

``` js
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.

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.

``` js
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.

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:

``` js
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.

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:

`MediaStream` test that exercises the real `<video>` → canvas → jsQR path, then connects two browser sessions and exchanges encrypted text in both directions;
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.

“Scanner failed” is not one condition. The interface should distinguish at least:

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.

Replacing `BarcodeDetector` was not about writing a QR algorithm. It was about making the browser boundary explicit:

The deployed example is the pairing scanner in [IcyZip's implementation](https://icyzip.com/how-icyzip-works). 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.
