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