{"slug": "a-browser-qr-scanner-without-barcodedetector-or-a-cdn", "title": "A browser QR scanner without BarcodeDetector or a CDN", "summary": "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.", "body_md": "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.\n\nThat 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.\n\nI 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.\n\nDisclosure: 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.\n\nThe useful capability test is not “does this browser have a QR API?” It is two smaller questions:\n\nThat produces a pipeline with replaceable parts:\n\n``` php\nuser action\n    -> getUserMedia()\n    -> video element\n    -> bounded canvas frame\n    -> local QR decoder\n    -> application-specific validation\n    -> stop camera\n    -> act on the result\n```\n\nThe 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.\n\nVendoring 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.\n\n``` js\nlet decoderPromise;\n\nfunction loadDecoder(version) {\n  if (typeof window.jsQR === \"function\") {\n    return Promise.resolve(window.jsQR);\n  }\n\n  if (!decoderPromise) {\n    decoderPromise = new Promise((resolve, reject) => {\n      const script = document.createElement(\"script\");\n      script.src = `/js/view/jsqr.js?v=${encodeURIComponent(version)}`;\n      script.async = true;\n      script.onload = () =>\n        typeof window.jsQR === \"function\"\n          ? resolve(window.jsQR)\n          : reject(new Error(\"QR decoder missing\"));\n      script.onerror = () => reject(new Error(\"QR decoder failed to load\"));\n      document.head.appendChild(script);\n    }).catch(error => {\n      decoderPromise = undefined; // allow a later retry\n      throw error;\n    });\n  }\n\n  return decoderPromise;\n}\n```\n\nThe 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.\n\nA 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.\n\nThe reader I use has two simple bounds:\n\nIt still schedules through `requestAnimationFrame`, but it skips work when the document is hidden, the video is not ready, or the interval has not elapsed.\n\n``` js\nconst MAX_EDGE = 640;\nconst DETECT_EVERY_MS = 250;\nlet lastDetectionAt = 0;\n\nfunction scanFrame(timestamp) {\n  frameRequest = requestAnimationFrame(scanFrame);\n\n  if (\n    document.hidden ||\n    video.readyState < 2 ||\n    !video.videoWidth ||\n    timestamp - lastDetectionAt < DETECT_EVERY_MS\n  ) {\n    return;\n  }\n\n  lastDetectionAt = timestamp;\n\n  const scale = Math.min(\n    1,\n    MAX_EDGE / Math.max(video.videoWidth, video.videoHeight)\n  );\n  const width = Math.max(1, Math.round(video.videoWidth * scale));\n  const height = Math.max(1, Math.round(video.videoHeight * scale));\n\n  canvas.width = width;\n  canvas.height = height;\n  context.drawImage(video, 0, 0, width, height);\n\n  const image = context.getImageData(0, 0, width, height);\n  const result = decode(image.data, width, height, {\n    inversionAttempts: \"attemptBoth\"\n  });\n\n  if (result?.data) handleDecodedValue(result.data);\n}\n```\n\nIn production, the canvas dimensions are changed only when necessary. That avoids reallocating the backing pixels on every detection attempt.\n\nThese 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.\n\nCamera 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.\n\nA 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.\n\n``` js\nlet scannerRun = 0;\nlet stream;\nlet frameRequest;\nlet canvas;\n\nfunction stopScanner() {\n  scannerRun += 1; // invalidate all pending work\n\n  if (frameRequest !== undefined) {\n    cancelAnimationFrame(frameRequest);\n    frameRequest = undefined;\n  }\n\n  stream?.getTracks().forEach(track => track.stop());\n  stream = undefined;\n\n  if (canvas) {\n    canvas.width = 0;\n    canvas.height = 0;\n    canvas = undefined;\n  }\n\n  video.srcObject = null;\n}\n\nasync function startScanner() {\n  stopScanner();\n  const run = scannerRun;\n\n  const newStream = await navigator.mediaDevices.getUserMedia({\n    audio: false,\n    video: { facingMode: { ideal: \"environment\" } }\n  });\n\n  if (run !== scannerRun) {\n    newStream.getTracks().forEach(track => track.stop());\n    return;\n  }\n\n  stream = newStream;\n  video.srcObject = stream;\n  await video.play();\n\n  if (run !== scannerRun) return;\n  const decode = await loadDecoder(CLIENT_VERSION);\n  if (run !== scannerRun) return;\n\n  // Create the canvas and begin the bounded frame loop here.\n}\n```\n\nCleanup 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.\n\nDecoding 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.\n\nFor example:\n\n``` js\nfunction parsePairingQr(rawValue) {\n  try {\n    const candidate = new URL(String(rawValue));\n\n    if (!ALLOWED_ORIGINS.has(candidate.origin)) return null;\n    if (candidate.protocol !== \"https:\") return null;\n    if (candidate.pathname !== \"/\") return null;\n    if (candidate.port) return null;\n    if (candidate.username || candidate.password) return null;\n\n    const pairingId = candidate.searchParams.get(\"i\");\n    const secret = parseExpectedFragment(candidate.hash);\n    if (!pairingId || !secret) return null;\n\n    return { candidate, pairingId, secret };\n  } catch {\n    return null;\n  }\n}\n```\n\nThe 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.\n\nThis 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.\n\nMocking 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:\n\n`MediaStream` test that exercises the real `<video>` → canvas → jsQR path, then connects two browser sessions and exchanges encrypted text in both directions;\nThe 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.\n\nThere 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.\n\n“Scanner failed” is not one condition. The interface should distinguish at least:\n\nEvery 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.\n\nReplacing `BarcodeDetector` was not about writing a QR algorithm. It was about making the browser boundary explicit:\n\nThe 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.", "url": "https://wpnews.pro/news/a-browser-qr-scanner-without-barcodedetector-or-a-cdn", "canonical_source": "https://dev.to/icyzip/a-browser-qr-scanner-without-barcodedetector-or-a-cdn-3joc", "published_at": "2026-10-04 00:51:59+00:00", "updated_at": "2026-10-04 01:07:58.601419+00:00", "lang": "en", "topics": ["developer-tools", "computer-vision", "ai-tools"], "entities": ["jsQR", "BarcodeDetector", "getUserMedia"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-browser-qr-scanner-without-barcodedetector-or-a-cdn", "markdown": "https://wpnews.pro/news/a-browser-qr-scanner-without-barcodedetector-or-a-cdn.md", "text": "https://wpnews.pro/news/a-browser-qr-scanner-without-barcodedetector-or-a-cdn.txt", "jsonld": "https://wpnews.pro/news/a-browser-qr-scanner-without-barcodedetector-or-a-cdn.jsonld"}}