Skip to content
All posts

CameraX in 2026: binding Preview and ImageAnalysis without leaking a camera session

A practical guide to CameraX on Android: binding Preview and ImageAnalysis to the lifecycle, the right backpressure strategy, and the crash rotation causes.

MFKAPPS 5 min read

Two of my apps point a camera at something and expect an answer back in under a second: Stocky scanning a barcode, Subly reading a bill. The vision models behind both — ML Kit’s barcode scanner and its text recognizer — get their own writeups elsewhere. This one is about the layer underneath both of them: CameraX, the part that has to stay open exactly as long as the screen showing the preview is alive, and not one frame longer.

CameraX is easy to get running and easy to get subtly wrong. The demo code binds a preview and an analyzer in a few lines and works on the first run. The bugs show up later — on rotation, on a fast back-and-forth between screens, on a phone that pauses the app mid-scan. Here’s what actually needs to be right.

Binding to a lifecycle, not an activity

The reason to use CameraX over raw Camera2 isn’t the API surface, it’s bindToLifecycle. Give it a LifecycleOwner along with your use cases, and CameraX starts the camera when that owner reaches STARTED and tears it down when it doesn’t — no manual onPause/onResume camera plumbing to get wrong:

val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

cameraProviderFuture.addListener({
    val cameraProvider = cameraProviderFuture.get()
    cameraProvider.unbindAll()

    val preview = Preview.Builder().build().also {
        it.surfaceProvider = previewView.surfaceProvider
    }

    val analysis = ImageAnalysis.Builder()
        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
        .build()
        .also { it.setAnalyzer(analysisExecutor, ::analyzeFrame) }

    cameraProvider.bindToLifecycle(
        lifecycleOwner,
        CameraSelector.DEFAULT_BACK_CAMERA,
        preview,
        analysis,
    )
}, ContextCompat.getMainExecutor(context))

That unbindAll() before the bind is not defensive boilerplate — it’s the fix for the single most common CameraX crash. ProcessCameraProvider.getInstance() returns a ListenableFuture, so the bind happens asynchronously, on a listener. If the screen recreates (rotation, a config change, navigating back to a scan screen that was already bound) before the old session was cleanly released, you get two use-case sets fighting over the same camera, or an IllegalStateException telling you a use case is already bound to a different lifecycle. Calling unbindAll() first makes every bind idempotent: it never matters how many times you’ve been through onResume.

Preview and analysis are two consumers of the same frames

Preview and ImageAnalysis aren’t stages in a pipeline — they’re two independent use cases the camera feeds simultaneously. The preview shows the user a live feed on a PreviewView; the analyzer gets its own ImageProxy per frame to run inference on. Binding both together is what makes “camera with a live scanning overlay” one call instead of two cameras. It also means the analyzer’s speed doesn’t affect what the user sees on screen — a slow model doesn’t make the preview laggy, which matters more than it sounds like it should for how “responsive” a scanner feels.

The backpressure strategy is not a minor setting

ImageAnalysis defaults to STRATEGY_KEEP_ONLY_LATEST in newer CameraX releases, but it’s worth setting explicitly, because the alternative — STRATEGY_BLOCK_PRODUCER — queues frames when your analyzer can’t keep up, and a barcode or OCR model rarely keeps up with a 30fps camera. KEEP_ONLY_LATEST drops every frame except the newest whenever the analyzer is still busy, which is exactly the behavior you want: the user sees a live preview and gets a result from whatever frame the model was free to look at, instead of the pipeline falling behind and processing frames from a second ago.

Close every ImageProxy, or the feed silently stalls

This is the bug that’s hardest to notice in testing because it doesn’t crash — it just stops. ImageAnalysis will not deliver a new frame to your analyzer until the previous ImageProxy is closed, even under KEEP_ONLY_LATEST. An early return on a null image, an exception thrown before your finally, or a lookup that runs async and closes the proxy only in its callback — any of these can leave one ImageProxy open forever, and from that frame on, the analyzer simply goes quiet. No error, no log, just a scanner that stopped scanning:

fun analyzeFrame(image: ImageProxy) {
    try {
        val media = image.image ?: return
        val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
        scanner.process(input).addOnSuccessListener { /* handle result */ }
    } finally {
        image.close()
    }
}

Put the close in a finally, not at the end of the happy path. It’s the one line in the whole setup that’s easy to skip and expensive to debug later.

Check the permission before you bind, not inside the callback

CameraX doesn’t request the camera permission for you, and it doesn’t fail gracefully if you bind without it — bindToLifecycle throws a SecurityException that will crash the screen if CAMERA hasn’t been granted yet. The fix is ordering: check ContextCompat.checkSelfPermission and get past ActivityResultContracts.RequestPermission before the ProcessCameraProvider listener ever runs, not inside it. A permission check that only runs after the user has already denied once, or that races the async provider future, is how you get a crash report that only reproduces on a fresh install.

The takeaway

CameraX earns its place over raw Camera2 in exactly one way: it turns “camera lifecycle management” into a solved problem, as long as you actually let it own the lifecycle. Bind through a LifecycleOwner, call unbindAll() before every bind so rotation and navigation can never double-bind a use case, set the backpressure strategy explicitly instead of hoping for the default, and close every ImageProxy in a finally. Get those four things right and the camera becomes boring — which, for a feature that has to work every single time a user points their phone at a barcode or a bill, is exactly what you want it to be.