cd /news/developer-tools/a-browser-qr-scanner-without-barcode… · home › topics › developer-tools › article
[ARTICLE · art-144657] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

A browser QR scanner without BarcodeDetector or a CDN

A developer built an in-page QR scanner that avoids the browser-native BarcodeDetector API and third-party CDNs by pairing getUserMedia and canvas with a self-hosted, pinned copy of the jsQR decoder. The implementation lazy-loads one same-origin versioned script only when scanning starts, caps frames at a 640-pixel edge, and decodes at most every 250ms to limit memory and battery use. The design keeps camera frames on-device and treats the decoder as an application dependency rather than a browser feature.

by read6 min views1 publishedOct 4, 2026

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:

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

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

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.

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

── more in #developer-tools 4 stories · sorted by recency
── more on @jsqr 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-browser-qr-scanner…] indexed:0 read:6min 2026-10-04 · —