Inaccurate & Unreliable Barcode Scanning on iOS Web App
04:17 01 Jul 2025

I'm building a React/TypeScript web app (PWA-style) that includes a barcode scanner. The scanner uses the BarcodeDetector API if it's supported; otherwise, I fall back to @zxing/library.

The issue: it works great on Android (fast and reliable), but it's super inaccurate on iOS. On iPhones, the scanner often doesn't detect anything, or worse, reads incorrect barcodes. Newer iPhones also struggle to focus on barcodes, making things even harder.

What I’ve tried so far:

  • Using getUserMedia with ideal and min resolutions
  • Setting facingMode: environment
  • Throttling the scan loop with setInterval
  • Using both native and zxing fallbacks

Has anyone figured out good constraints or tricks (resolution, frame rate, etc.) that improve performance on iOS Safari? Any best practices for working around iOS focus issues or bad decoding quality?

I'm aware of html5-qrcode, but I’d rather tune the lower-level logic myself unless that lib has magic sauce for iOS I’m missing.

My Current Scanner Implementation:

import { useState, useRef, useCallback, useEffect } from 'react';
import { BrowserMultiFormatReader, IScannerControls } from '@zxing/library';

interface BarcodeResult {
  code: string;
  format: string;
}

export function useBarcodeScanner() {
  const [state, setState] = useState({
    isScanning: false,
    error: null,
    lastResult: null
  });

  const videoRef = useRef(null);
  const zxingControlsRef = useRef(null);
  const isInitializedRef = useRef(false);
  const codeReaderRef = useRef(null);

  const getCodeReader = useCallback(() => {
    if (!codeReaderRef.current) {
      codeReaderRef.current = new BrowserMultiFormatReader();
    }
    return codeReaderRef.current;
  }, []);

  const stopScanning = useCallback(() => {
    if (zxingControlsRef.current) {
      zxingControlsRef.current.stop();
      zxingControlsRef.current = null;
    } else if (videoRef.current && videoRef.current.srcObject) {
      const tracks = (videoRef.current.srcObject as MediaStream).getTracks();
      tracks.forEach(track => { if (track.readyState === 'live') track.stop(); });
    }
    isInitializedRef.current = false;
    setState(prev => ({ ...prev, isScanning: false }));
  }, []);

  const startScanning = useCallback(async (onResult: (result: BarcodeResult) => void) => {
    if (isInitializedRef.current) return;

    setState(prev => ({ ...prev, isScanning: true, error: null }));
    isInitializedRef.current = true;

    try {
      if ('BarcodeDetector' in window) {
        // --- Native BarcodeDetector Logic ---
        const stream = await navigator.mediaDevices.getUserMedia({
          video: {
            facingMode: 'environment',
            width: { ideal: 1280, min: 640 },
            height: { ideal: 720, min: 480 }
          }
        });
        if (videoRef.current) {
          videoRef.current.srcObject = stream;
          videoRef.current.setAttribute('playsinline', 'true');
          await videoRef.current.play();

          const barcodeDetector = new (window as any).BarcodeDetector({
            formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'upc_a', 'upc_e']
          });

          let scanLoopInterval: NodeJS.Timeout;
          const scanLoop = async () => {
            if (!videoRef.current || videoRef.current.paused || videoRef.current.ended || !isInitializedRef.current) {
              if (scanLoopInterval) clearInterval(scanLoopInterval);
              return;
            }
            try {
              const barcodes = await barcodeDetector.detect(videoRef.current);
              if (barcodes.length > 0) {
                const barcode = barcodes[0];
                const result: BarcodeResult = { code: barcode.rawValue, format: barcode.format };
                setState(prev => ({ ...prev, lastResult: result }));
                onResult(result);
                stopScanning(); // Stop after successful scan
              }
            } catch (err) {  }
          };
          scanLoopInterval = setInterval(scanLoop, 300);
        }
      } else {
        // --- ZXing Fallback Logic ---
        const codeReader = getCodeReader();
        const videoElement = videoRef.current;
        if (!videoElement) throw new Error('Video element not found for ZXing scanner.');

        codeReader.decodeFromVideoDevice(undefined, videoElement, (result, error, controls) => {
          if (result) {
            const barcodeData: BarcodeResult = { code: result.getText(), format: result.getBarcodeFormat().toString() };
            setState(prev => ({ ...prev, lastResult: barcodeData }));
            onResult(barcodeData);
            controls.stop();
            zxingControlsRef.current = null;
            isInitializedRef.current = false;
          }
          if (error && !error.message.includes('No MultiFormat Readers were able to decode')) {
            setState(prev => ({ ...prev, error: `Camera-Error: ${error.message}` }));
            stopScanning();
          }
        })
        .then(controls => { zxingControlsRef.current = controls; })
        .catch(err => {
          setState(prev => ({ ...prev, isScanning: false, error: err.message || 'Fehler beim Starten des Scanners mit ZXing.' }));
          stopScanning();
        });
      }
    } catch (err: any) {
      setState(prev => ({ ...prev, isScanning: false, error: err.message || 'Kamera-Zugriff oder Wiedergabe fehlgeschlagen' }));
      stopScanning();
    }
  }, [stopScanning, getCodeReader]);

  useEffect(() => { return () => stopScanning(); }, [stopScanning]);

  return { ...state, videoRef, startScanning, stopScanning };
}

BarcodeScanner.tsx that renders a element for the camera feed.

import React, { useEffect, useState } from 'react';
import { X, Camera, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useBarcodeScanner } from '../hooks/useBarcodeScanner';
import { useOpenFoodFacts } from '../hooks/useOpenFoodFacts';

interface BarcodeScannerProps {
  onProductFound: (productData: any) => void;
  onClose: () => void;
}

const BarcodeScanner: React.FC = ({ onProductFound, onClose }) => {
  const { isScanning, error: scanError, videoRef, startScanning, stopScanning } = useBarcodeScanner();
  const { loading: fetchingProduct, error: fetchError, fetchProduct, clearError } = useOpenFoodFacts();
  const [scanResult, setScanResult] = useState(null);
  const [productFound, setProductFound] = useState(false);

  useEffect(() => {
    if (!scanResult && !isScanning) {
      startScanning(async (result) => {
        setScanResult(result.code);
        const productData = await fetchProduct(result.code);
        if (productData) {
          setProductFound(true);
          setTimeout(() => onProductFound(productData), 1500);
        }
      });
    }
    return () => stopScanning();
  }, [startScanning, stopScanning, fetchProduct, onProductFound, scanResult, isScanning]);

 ......

export default BarcodeScanner;
ios reactjs web-applications zxing barcode-scanner