AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Debug Barcode & QR Scanning Crashes on iOS & Android

NFNourin Mahfuj Finick··9 min read

Barcode scanning crashes are some of the most maddening mobile bugs to debug because they almost never reproduce at your desk. The scanner that works flawlessly under bright office lights and strong Wi-Fi falls apart in a dim warehouse, on a two-year-old Android handset, or the instant a user taps "Don't Allow" on the camera permission prompt. Whether you build the feature with Apple's AVCaptureMetadataOutput or Vision's VNDetectBarcodesRequest on iOS, or with CameraX plus ML Kit Barcode Scanning on Android, nearly every QR code crash traces back to a small, predictable set of root causes: camera permission denials, delegate-queue threading mistakes, and session-lifecycle mismanagement. QR scanning is now a mainstream interaction — Statista projects steady growth in U.S. mobile QR scanner usage through 2025 — which means these failures are hitting real users every day. This guide walks through the failure surface on both platforms, with concrete fixes, and explains how to catch the crashes that still slip through with a mobile crash reporting tool like Bugspulse.

The shared failure surface

Across iOS and Android, barcode scanners crash for three reasons more than any others. Understand these first and most of the rest of the guide becomes obvious.

Camera permissions are the number-one killer

Both platforms guard the camera behind a runtime permission, and both crash or silently fail when the grant is missing, denied, or revoked mid-session. On iOS, AVCaptureDeviceInput construction throws if you have not declared NSCameraUsageDescription in Info.plist and requested authorization. On Android, you need the CAMERA permission declared in the manifest and granted at runtime before CameraX can bind any use case — the Android manifest documentation lists CAMERA as a dangerous permission that requires an explicit runtime request.

The subtle crashes come from the edge cases: a user who granted permission, backgrounded the app, and had the grant revoked from Settings; a user who tapped "Ask every time" on Android 11+ and gets a one-time grant; or a scanner screen that assumes the grant persists across app launches. Check authorization on every entry to the scanner screen, not just once at app start, and treat "denied" and "restricted" as separate states with a clear recovery path. For a deeper treatment of permission-state handling across the lifecycle, see our runtime permission debugging guide.

Threading is where scanners crash silently

Both AVCaptureMetadataOutput and ML Kit deliver results on a queue you specify, and violating that queue's contract is a classic source of sporadic crashes. On iOS, the metadata delegate fires on the queue you passed to setMetadataObjectsDelegate(_:queue:); updating UIKit on a background queue there is undefined behavior. On Android, ML Kit's BarcodeScanner.process() is asynchronous, and its listener callbacks arrive on the calling thread — if you call process() from a background analyzer thread and then touch the UI directly, you have a race condition. We cover the general patterns in our mobile thread safety guide.

Session lifecycle is a state machine

AVCaptureSession on iOS and CameraX's ProcessCameraProvider on Android are both stateful objects with strict start/stop rules. Starting an already-running session, stopping a session from the wrong thread, or leaving a session running while the view controller tears down are all reliable ways to produce AVCaptureSessionRuntimeError on iOS or a leaked camera on Android. The session must begin and end with the screen's lifecycle, not the app's.

iOS: AVCaptureMetadataOutput, VisionKit, and Vision

iOS gives you three distinct scanning APIs, and each has its own crash signature.

AVCaptureMetadataOutput session lifecycle

AVCaptureMetadataOutput is the workhorse for live scanning. The most common crash here is calling startRunning() on the main thread — Apple's AVCaptureSession documentation explicitly warns that startRunning() is blocking and should be moved off the main thread in production. The second-most common is configuring metadataObjectTypes with symbologies the current device does not support, which can raise an exception; always intersect your desired types with availableMetadataObjectTypes:

let session = AVCaptureSession()
let output = AVCaptureMetadataOutput()
 
// Configure on the main thread.
session.beginConfiguration()
if session.canAddOutput(output) { session.addOutput(output) }
session.commitConfiguration()
 
// Deliver callbacks on a dedicated queue; hop to main for UI.
let queue = DispatchQueue(label: "barcode.metadata")
output.setMetadataObjectsDelegate(self, queue: queue)
 
// Intersect with what the device actually supports.
output.metadataObjectTypes = output.availableMetadataObjectTypes
 
// Add the camera input after checking permission.
guard let device = AVCaptureDevice.default(for: .video),
      let input = try? AVCaptureDeviceInput(device: device),
      session.canAddInput(input) else { return }
session.addInput(input)
 
// Never block the main thread with startRunning().
DispatchQueue.global(qos: .userInitiated).async {
    session.startRunning()
}

Watch the AVCaptureSessionRuntimeError notification too. It fires when the session hits a hardware-level failure — camera in use by another process, a thermal event, or a media-services reset — and it frequently surfaces as a crash if you do not listen and tear down gracefully. When you receive it, stop the session, release the camera input, and offer the user a retry path.

VisionKit DataScannerViewController

For SwiftUI and UIKit apps on iOS 16+, DataScannerViewController is the modern replacement for a hand-rolled metadata pipeline, and it removes an entire class of configuration bugs. It handles the session, the permission prompt, and symbology configuration internally — Apple's documentation notes that it supports multiple barcode symbologies out of the box. The two things that still bite you are the permission check and the availability guard: call DataScannerViewController.isSupported and isAvailable before presenting, because on unsupported devices or when camera access is denied, presenting it anyway is the crash you are trying to avoid.

Vision VNDetectBarcodesRequest for still images

When you are scanning a saved image or a screenshot rather than a live feed, use Vision. VNDetectBarcodesRequest runs on a VNImageRequestHandler and returns VNBarcodeObservation results asynchronously, which sidesteps the entire camera session. Vision detects QR, Aztec, Code 128, EAN-13, and more, returning each match as a VNBarcodeObservation:

let request = VNDetectBarcodesRequest { request, error in
    guard error == nil else { return }
    let results = request.results as? [VNBarcodeObservation] ?? []
    for barcode in results {
        let value = barcode.payloadStringValue ?? ""
        let symbol = barcode.symbology.rawValue
        print("\(symbol): \(value)")
    }
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try? handler.perform([request])

The crash trap here is orientation and region-of-interest handling, plus the fact that Vision callbacks also arrive on a background queue. Do your UI work on the main thread and do not assume the image orientation is upright before you extract the payload.

Android: CameraX, ML Kit, and the legacy stack

Android's barcode landscape is split between the modern CameraX + ML Kit path and the older ZXing IntentIntegrator path, and crash triage starts with knowing which one you are on.

CameraX ImageAnalysis threading

CameraX delivers frames to an ImageAnalysis.Analyzer on the executor you provide. If you use the main executor, you will jank and eventually ANR; if you use a shared executor and forget to close each ImageProxy, the analyzer stalls and the camera pipeline throws buffer overrun errors. Always use a single-thread executor for analysis and close the proxy in a completion handler, not before ML Kit is done reading the frame:

val options = BarcodeScannerOptions.Builder()
    .setBarcodeFormats(Barcode.FORMAT_QR_CODE, Barcode.FORMAT_AZTEC)
    .build()
val scanner = BarcodeScanning.getClient(options)
 
val analysis = ImageAnalysis.Builder()
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()
 
analysis.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy ->
    val mediaImage = imageProxy.image
    if (mediaImage != null) {
        val image = InputImage.fromMediaImage(
            mediaImage, imageProxy.imageInfo.rotationDegrees)
        scanner.process(image)
            .addOnSuccessListener { barcodes -> /* post results to main thread */ }
            .addOnCompleteListener { imageProxy.close() }
    } else {
        imageProxy.close()
    }
}

The rotationDegrees argument matters more than most developers realize. If you feed ML Kit a rotated InputImage with the wrong rotation value, it returns garbage bounding boxes or nothing at all — and code that blindly indexes into an empty result list is the crash.

ML Kit InputImage rotation and listeners

ML Kit Barcode Scanning is the recommended on-device path, and its two crash-prone areas are input-image rotation and listener threading. Always pass imageProxy.imageInfo.rotationDegrees when building the InputImage, and always marshal listener results back to the main thread before touching views. ML Kit also caps the number of concurrent requests; if you queue process() calls without awaiting completion you can hit resource limits on low-RAM devices.

Google Code Scanner and ZXing

Google's Code Scanner is a self-contained UI that bundles its own camera flow and returns results through the Activity Result API. It removes most camera and permission plumbing, but its crash surface is lifecycle-based: launching it without a valid ActivityResultLauncher registration, or launching during a configuration change, will throw. The legacy ZXing IntentIntegrator path is even more lifecycle-sensitive — it wraps the camera in a separate activity and is deprecated in favor of the paths above, but it still shows up in old codebases. If you are still on ZXing, the top crash is calling initiateScan() after the host activity is destroyed, plus the missing CAMERA manifest entry that only fails on some devices.

A cross-platform crash-prevention checklist

Before you ship, run down this list; it catches the overwhelming majority of barcode scanning crashes in review rather than production:

  • Declare NSCameraUsageDescription on iOS and the CAMERA permission on Android before the first camera access.
  • Re-check authorization on every entry to the scanner screen, and handle "denied," "restricted," and one-time grants as distinct states.
  • Intersect metadataObjectTypes with availableMetadataObjectTypes; never assume a symbology exists.
  • Call startRunning() off the main thread, and stop the session in viewDidDisappear/onPause, not in deinit/onDestroy.
  • Give every metadata delegate and ML Kit listener a dedicated queue, and marshal all UI updates to the main thread.
  • Close every ImageProxy exactly once and only after ML Kit is finished with it.
  • Pass the correct rotation to InputImage.fromMediaImage and guard against empty result lists.
  • Listen for AVCaptureSessionRuntimeError and release the camera cleanly on hardware failures.
  • Watch for torch and battery leaks: leave the torch on after the scanner closes and you drain the battery while the camera stays hot.

If your scanner code touches raw camera access at all — preview, capture, or torch — the failure modes overlap heavily with general camera work, so our camera and media crash debugging guide is worth keeping open alongside this one.

Ship a scanner that does not crash in the field

The pattern is the same whether the stack is Swift or Kotlin: fix permissions, respect the delegate queue, and treat the camera session as a lifecycle-bound state machine. But barcode scanning crashes are exactly the kind that slip past a test suite — device-specific camera hardware, one-time permission grants, and low-light off-screen scanning rarely surface in CI. That is where production crash reporting earns its keep. Bugspulse captures the full stack trace with breadcrumbs and device context so you can see the exact symbology, permission state, and thread behind a crash, then ship a fix before your review average drops.

Ready to see every barcode scanning crash your users are actually hitting? Start your free Bugspulse account and get production crash reporting set up in minutes.