DEV Community

İlhan Neğiş
İlhan Neğiş

Posted on

Barcode Scanning on iOS: The Missing Web API and a WebAssembly Solution

If you've ever tried to build a web based barcode scanner targeting iOS, you've likely hit a wall: Safari doesn't support the Barcode Detection API^.

tldr; Live demo: https://eringen.com/workbench/wasm-barcode/^ (NPM version)

source version: https://eringen.com/workbench/wasm-barcode/index2.html^ (With rotation passes visible)

The Problem

The Barcode Detection API is part of the Shape Detection API spec and provides a clean, native way to detect barcodes from images or camera feeds in the browser. Chrome on Android has supported it for a while, but Safari and by extension every browser on iOS, since they all use WebKit under the hood simply doesn't implement it.

This means any web app relying on BarcodeDetector will silently fail on iPhones and iPads. For projects that need crossplatform barcode scanning without a native app, this is a dealbreaker.

The Usual Workarounds

Most JavaScript barcode libraries tackle this with pure JS decoding. They work, but the performance cost is noticeable especially on older iOS devices where you need smooth, realtime scanning from a camera feed. Dropped frames and slow detection make for a poor user experience.

A WebAssembly Approach

Instead of decoding barcodes in JavaScript, we can compile a proven C library ZBar^ to WebAssembly using Emscripten. ZBar has been around for years and handles a wide range of barcode formats reliably.

The workflow is straightforward:

  1. Capture camera frames using getUserMedia
  2. Crop to a region of interest and convert to grayscale in JavaScript
  3. Pass the pixel data to ZBar running in WebAssembly
  4. Get back the decoded barcode type, data, and bounding polygon

Because the heavy lifting happens in compiled WASM rather than interpreted JavaScript, the performance is near native. On iOS devices, this translates to fast, responsive scanning that feels like a native app.

From Prototype to Production

A big shout out to Berkley Wolf and his blog post, I was able to cobble together a prototype quickly, pay him a visit: Using the ZBar barcode scanning suite in the browser with WebAssembly^

The initial prototype worked, but it was a single index.js file with global state, parseInt for rounding, implicit globals, and a static PNG overlay. Useful for proving the concept, not so much for shipping in a real app or embedding in a React/Vue project.

Here's what the modernization looked like.

TypeScript + Vite

The codebase moved from a plain <script> tag to TypeScript with strict: true and noImplicitAny, built with Vite. The Emscripten generated a.out.js stays as a classic script (it needs to land on window.Module), but everything else is typed. A src/types/emscripten.d.ts file declares the subset of the Emscripten API we use: cwrap, HEAP8, HEAP32, UTF8ToString, and the custom processResult callback.

This caught real bugs during the port. For example, the original code did parseInt(cameraWidth * 0.702) where Math.floor was the correct operation, parseInt expects a string. TypeScript flagged these immediately.

Animated CSS Overlay

The original project used a static barcodelayout.png image positioned over the video to show the scan region. The new version replaces it with a programmatic overlay: four dark mask divs surrounding a transparent scan region, "L" shaped corner brackets, and a sweeping laser line, all pure CSS with a @keyframes animation. The overlay injects its styles via a single deduplicated <style> tag and positions everything using CSS custom properties derived from the scan region dimensions.

This eliminates an external asset, scales to any container size, and gives users clear visual feedback that something is actively scanning.

The BarcodeScanner Class

The core of the rewrite is a single exportable class:

const scanner = new BarcodeScanner({
  container: document.getElementById('scanner-mount')!,
  onDetect: (result) => console.log(result.symbol, result.data),
});

await scanner.start();
// later...
scanner.stop();
Enter fullscreen mode Exit fullscreen mode

The constructor stores configuration only, no side effects. This matters for React strict mode, which double invokes effects in development. start() handles the full initialization chain: DOM setup, WASM loading, camera acquisition, result handler wiring, and the scan interval. stop() tears everything down cleanly including stopping camera tracks, something the original code never did.

The class is designed to be wrapped. A React component is just a useEffect that calls start() and returns stop():

useEffect(() => {
  const scanner = new BarcodeScanner({
    container: ref.current!,
    onDetect: (result) => onScan(result.data),
  });
  scanner.start();
  return () => scanner.stop();
}, [onScan]);
Enter fullscreen mode Exit fullscreen mode

Rotation Based Skew Correction

In the real world, users don't hold barcodes perfectly straight. The original scanner only looked at 0 degrees, meaning a slight tilt could cause a miss.

The new scan loop tries three angles per tick: 0 degrees, +30 degrees, and -30 degrees. The key insight is that this can use early exit. If ZBar finds a barcode at 0 degrees, we skip the rotated passes entirely. Most scans only need one pass. The rotation itself is just a Canvas 2D translate-rotate-translate transform before drawing the video frame to the offscreen canvas:

// Rotate around the center of the offscreen canvas
ctx.translate(w / 2, h / 2);
ctx.rotate(angle);
ctx.translate(-w / 2, -h / 2);

// Draw the video crop (now rotated)
ctx.drawImage(video, srcX, srcY, srcW, srcH, 0, 0, w, h);
Enter fullscreen mode Exit fullscreen mode

The rotation cost is minimal since we're only rotating a small cropped region (roughly 700 by 240 pixels), not the full camera frame.

A WASM Memory Pitfall

One bug that took some digging: the original code allocated a new WASM heap buffer every scan tick but never freed it. A memory leak, but it worked because ZBar's zbar_image_free_data callback actually freed the buffer when the image was destroyed at the end of scan_image.

The modernized version initially tried to be clever by allocating one buffer and reusing it. This was use after free: ZBar freed the buffer at the end of the first scan_image call, and every subsequent write to that pointer corrupted the WASM heap. It would run for about 70 seconds (around 475 successful scans) before crashing with RuntimeError: memory access out of bounds.

The fix was simple: allocate a fresh buffer per scan_image call, matching the original behavior. ZBar handles cleanup. The lesson: when bridging JS and WASM, understand who owns the memory. In scan.c:

// This registers zbar_image_free_data as the cleanup handler.
// When zbar_image_destroy runs, it calls free() on our buffer.
zbar_image_set_data(image, raw, width * height, zbar_image_free_data);
Enter fullscreen mode Exit fullscreen mode

Video Resolution Scaling

Another subtle fix: the original code assumed the camera delivered exactly 2x the container size. It requested 1000x1000 for a 500x500 container and hardcoded barcodeOffset * 2 as the drawImage source coordinates. If the camera returned a different resolution (common across devices), the crop region was wrong.

The new code reads video.videoWidth and video.videoHeight each tick and computes a proper scale factor:

const scaleX = videoW / this.cameraWidth;
const scaleY = videoH / this.cameraHeight;
const srcX = this.barcodeOffsetX * scaleX;
Enter fullscreen mode Exit fullscreen mode

This works regardless of the actual camera resolution.

Debug Preview Panel

For development and troubleshooting, the scanner accepts an optional previewCanvas element. When provided, it renders all three rotation passes stacked vertically in real time. The angle that detected a barcode gets a green border. This makes it trivial to see exactly what ZBar is processing at each angle, useful both for development and for demos.

Results

In practice, the WASM scanner detects barcodes within a couple of frames on modern iPhones. The scan loop runs at roughly 150ms intervals, and ZBar's processing time per frame is negligible. Combined with the animated overlay, audio feedback on detection, and rotation based skew correction, the experience is smooth enough that users won't notice they're using a web app.

The rotation passes add real value. In testing, barcodes tilted at around 25-30 degrees that the original scanner missed entirely are now caught on the second or third pass of the same tick.

What's Supported

ZBar handles most common 1D formats (EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, ISBN, Interleaved 2 of 5, DataBar) and QR codes. It does not support Data Matrix, PDF417, or Aztec. For those, you'd need a different WASM library like ZXing.

Takeaway

Until Apple adds Barcode Detection API support to Safari, WebAssembly is the best path to performant barcode scanning on iOS. By leveraging real world tested C libraries through WASM, we get reliability and speed without waiting for browser vendors to catch up.

The TypeScript rewrite makes the scanner embeddable in any modern frontend stack. The BarcodeScanner class handles the full lifecycle, camera permissions, WASM initialization, scan loop, cleanup, so your framework wrapper only needs to call start() and stop().

The full source is available at https://github.com/eringen/web-wasm-barcode-reader^.

NPM package: https://www.npmjs.com/package/web-wasm-barcode-reader^

npm i web-wasm-barcode-reader
Enter fullscreen mode Exit fullscreen mode

Usage as npm Package

The library requires the Emscripten WASM glue script (a.out.js) to be loaded before the scanner is started. The WASM binary (a.out.wasm) is fetched automatically by the glue script at runtime.

  1. Copy the WASM files into your public/static directory After installing, copy the WASM assets to a location your web server can serve:
cp node_modules/web-wasm-barcode-reader/public/a.out.js  public/
cp node_modules/web-wasm-barcode-reader/public/a.out.wasm public/
Enter fullscreen mode Exit fullscreen mode
  1. Serve index.html as show.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Barcode Scanner</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      background: #0f172a;
      color: #e2e8f0;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    h1 {
      margin: 1.5rem 0 1rem;
      font-size: 1.5rem;
      font-weight: 600;
    }

    #scanner-container {
      width: 100%;
      max-width: 480px;
      aspect-ratio: 1;
      background: #1e293b;
      border-radius: 12px;
      overflow: hidden;
      position: relative;
    }

    #controls {
      display: flex;
      gap: 0.75rem;
      margin: 1rem 0;
    }

    button {
      padding: 0.6rem 1.4rem;
      border: none;
      border-radius: 8px;
      font-size: 0.95rem;
      font-weight: 500;
      cursor: pointer;
      transition: background 0.2s;
    }

    #start-btn {
      background: #22c55e;
      color: #fff;
    }
    #start-btn:hover { background: #16a34a; }
    #start-btn:disabled { background: #4b5563; cursor: not-allowed; }

    #stop-btn {
      background: #ef4444;
      color: #fff;
    }
    #stop-btn:hover { background: #dc2626; }
    #stop-btn:disabled { background: #4b5563; cursor: not-allowed; }

    #torch-btn {
      background: #eab308;
      color: #1e293b;
    }
    #torch-btn:hover { background: #ca8a04; }
    #torch-btn:disabled { background: #4b5563; color: #9ca3af; cursor: not-allowed; }

    #results {
      width: 100%;
      max-width: 480px;
      margin-bottom: 2rem;
    }

    #results h2 {
      font-size: 1.1rem;
      margin-bottom: 0.5rem;
      color: #94a3b8;
    }

    #result-list {
      list-style: none;
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
    }

    .result-item {
      background: #1e293b;
      border: 1px solid #334155;
      border-radius: 8px;
      padding: 0.75rem 1rem;
      display: flex;
      justify-content: space-between;
      align-items: center;
      animation: fadeIn 0.3s ease;
    }

    .result-item .data {
      font-family: "SF Mono", "Fira Code", monospace;
      font-size: 0.95rem;
      word-break: break-all;
    }

    .result-item .type {
      font-size: 0.75rem;
      background: #334155;
      padding: 0.2rem 0.5rem;
      border-radius: 4px;
      color: #94a3b8;
      white-space: nowrap;
      margin-left: 0.75rem;
    }

    .result-item .copy-btn {
      background: none;
      border: 1px solid #475569;
      color: #94a3b8;
      padding: 0.3rem 0.6rem;
      font-size: 0.75rem;
      border-radius: 4px;
      margin-left: 0.5rem;
      flex-shrink: 0;
    }
    .result-item .copy-btn:hover { background: #334155; color: #e2e8f0; }

    #status {
      font-size: 0.85rem;
      color: #64748b;
      margin-bottom: 0.5rem;
      min-height: 1.2em;
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(-4px); }
      to { opacity: 1; transform: translateY(0); }
    }
  </style>
</head>
<body>
  <h1>Barcode Scanner</h1>
  <p id="status">Press Start to begin scanning</p>

  <div id="scanner-container"></div>

  <div id="controls">
    <button id="start-btn">Start</button>
    <button id="stop-btn" disabled>Stop</button>
    <button id="torch-btn" disabled>Torch</button>
  </div>

  <div id="results">
    <h2>Scanned Results</h2>
    <ul id="result-list"></ul>
  </div>

  <!-- Emscripten glue must load as a classic script before the module -->
  <script>
    var Module = {
      locateFile: function(path) {
        return '/public/' + path;
      }
    };
  </script>
  <script src="/public/a.out.js"></script>
  <script type="module">
    import { BarcodeScanner } from './public/web-wasm-barcode-reader.js';

    const container = document.getElementById('scanner-container');
    const startBtn = document.getElementById('start-btn');
    const stopBtn = document.getElementById('stop-btn');
    const torchBtn = document.getElementById('torch-btn');
    const resultList = document.getElementById('result-list');
    const status = document.getElementById('status');

    let scanner = null;
    const seenCodes = new Set();

    function addResult(result) {
      // Deduplicate consecutive identical scans
      const key = result.symbol + ':' + result.data;
      if (seenCodes.has(key)) return;
      seenCodes.add(key);

      const li = document.createElement('li');
      li.className = 'result-item';

      const dataSpan = document.createElement('span');
      dataSpan.className = 'data';
      dataSpan.textContent = result.data;

      const typeSpan = document.createElement('span');
      typeSpan.className = 'type';
      typeSpan.textContent = result.symbol;

      const copyBtn = document.createElement('button');
      copyBtn.className = 'copy-btn';
      copyBtn.textContent = 'Copy';
      copyBtn.addEventListener('click', () => {
        navigator.clipboard.writeText(result.data).then(() => {
          copyBtn.textContent = 'Copied!';
          setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
        });
      });

      li.appendChild(dataSpan);
      li.appendChild(typeSpan);
      li.appendChild(copyBtn);
      resultList.prepend(li);
    }

    startBtn.addEventListener('click', async () => {
      startBtn.disabled = true;
      status.textContent = 'Starting camera...';

      scanner = new BarcodeScanner({
        container,
        onDetect: (result) => {
          addResult(result);
          status.textContent = `Detected: ${result.symbol} - ${result.data}`;
        },
        onError: (err) => {
          status.textContent = 'Error: ' + err.message;
          console.error(err);
        },
        beepOnDetect: true,
        facingMode: 'environment',
      });

      try {
        await scanner.start();
        status.textContent = 'Scanning... point camera at a barcode';
        stopBtn.disabled = false;
        torchBtn.disabled = false;
      } catch (err) {
        status.textContent = 'Failed to start: ' + err.message;
        startBtn.disabled = false;
        scanner = null;
      }
    });

    stopBtn.addEventListener('click', () => {
      if (scanner) {
        scanner.stop();
        scanner = null;
      }
      startBtn.disabled = false;
      stopBtn.disabled = true;
      torchBtn.disabled = true;
      status.textContent = 'Scanner stopped';
    });

    torchBtn.addEventListener('click', async () => {
      if (!scanner) return;
      try {
        const on = await scanner.toggleTorch();
        torchBtn.textContent = on ? 'Torch Off' : 'Torch';
      } catch (err) {
        status.textContent = 'Torch not supported on this device';
      }
    });
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)