loke.dev
Header image for How to Scan QR Codes in the Browser Without a Heavy Third-Party Library

How to Scan QR Codes in the Browser Without a Heavy Third-Party Library

Leverage the high-performance Shape Detection API to handle QR codes and barcodes natively, eliminating the need for bulky WASM or JavaScript dependencies.

· 3 min read

Why are we still shipping multi-megabyte WASM blobs and massive JavaScript bundles just to read a simple black-and-white square?

I recently looked into adding a QR scanner to a side project and was immediately exhausted by the options. Most "go-to" libraries are heavy, have a million dependencies, or require you to bundle a full C++ engine compiled to WebAssembly. For a simple feature, that felt like bringing a chainsaw to a steak dinner.

It turns out that modern browsers have a native solution hiding in plain sight: the Barcode Detection API. It’s part of the broader Shape Detection API, and it’s fast because it offloads the heavy lifting to the operating system's hardware acceleration.

Is your browser even cool enough?

Before we get excited, we have to talk about the elephant in the room: browser support. As of right now, this is primarily a Chromium-based party (Chrome, Edge, Opera, and Chrome on Android). Safari and Firefox are still dragging their feet.

But here’s the thing: in a progressive enhancement world, you can use the native API for 80% of your users and only load that heavy third-party library as a fallback for the rest.

Here is how you check if the browser is ready to play:

if ('BarcodeDetector' in window) {
  console.log('Native Barcode Detector supported!');
} else {
  console.log('Time to load the bulky polyfill...');
}

The "Hello World" of Native Scanning

The API is refreshingly simple. You initialize a BarcodeDetector instance, tell it what formats you’re looking for (it handles more than just QR codes!), and pass it an image source.

const detector = new BarcodeDetector({
  formats: ['qr_code', 'code_128', 'ean_13']
});

// You can pass a video element, an image, or even a canvas
const barcodes = await detector.detect(someImageSource);

barcodes.forEach(barcode => {
  console.log(`Found: ${barcode.rawValue}`);
});

Making it work with a Live Camera

Scanning a static image is fine, but usually, we want that "point and scan" experience. We need to hook up the getUserMedia API to a <video> element and then feed those frames into our detector.

First, let's get the camera stream running:

const video = document.getElementById('scanner-video');

async function startCamera() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ 
      video: { facingMode: 'environment' } 
    });
    video.srcObject = stream;
    video.play();
  } catch (err) {
    console.error("Camera access denied!", err);
  }
}

Now, we need a loop. You *could* use setInterval, but we're pros, so we’re going to use requestAnimationFrame for a smoother experience.

async function renderLoop() {
  try {
    const barcodes = await detector.detect(video);
    if (barcodes.length > 0) {
      // We found something! 
      alert(`Scanned: ${barcodes[0].rawValue}`);
      // Stop scanning if you only need one result
      return; 
    }
  } catch (e) {
    // Sometimes detection fails if the video frame is empty
  }
  
  // Keep the loop going
  requestAnimationFrame(renderLoop);
}

Let's talk about performance (and your battery)

One reason I love the native API is that it doesn't turn the user's phone into a space heater. When you use a JS-based library, your CPU is sweating through every single pixel on every single frame.

The BarcodeDetector taps into the OS-level vision frameworks (like CoreImage on macOS or the Play Services Barcode API on Android). This means the browser isn't "calculating" the QR code; it's just asking the operating system, "Hey, see anything interesting here?"

Dealing with the "Not Supported" headache

Since support isn't universal, I usually wrap this in a helper function. I try to go native first, and only if that fails do I dynamically import a library like jsQR.

async function getScanner() {
  if ('BarcodeDetector' in window) {
    const formats = await BarcodeDetector.getSupportedFormats();
    if (formats.includes('qr_code')) {
      return new BarcodeDetector({ formats: ['qr_code'] });
    }
  }
  
  // If we're here, we need to load a fallback
  const { default: jsQR } = await import('https://cdn.jsdelivr.net/npm/jsqr@1.4.0/+esm');
  
  // Return a wrapper that mimics the native API's detect method
  return {
    detect: (videoElement) => {
      // (Simplified logic) Draw video to canvas, get ImageData, run jsQR
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      ctx.drawImage(videoElement, 0, 0);
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const code = jsQR(imageData.data, imageData.width, imageData.height);
      return code ? [{ rawValue: code.data }] : [];
    }
  };
}

A few gotchas to keep in mind

1. Secure Contexts: This API only works over HTTPS (or localhost). If you're testing on a local network device without a cert, it’ll be undefined.
2. Flags: In some versions of Chrome for Desktop, you might still need to enable the "Experimental Web Platform features" flag in chrome://flags, though it has been rolling out to stable.
3. The "formats" catch: Not all hardware supports all formats. BarcodeDetector.getSupportedFormats() is your best friend here. It tells you exactly what the underlying hardware is capable of seeing.

Stop bloating your node_modules for features the browser can already do. Native is faster, leaner, and—honestly—much more fun to write. Give it a shot on your next internal tool or PWA. Your users' data plans will thank you.