MVP Factory
ai startup development

Android barcode scanner + on-device LLM: the latency budget

KW
Krystian Wiewiór · · 5 min read

Meta description: Wire ML Kit barcode detection into MediaPipe LLM Inference with frame throttling and structured JSON output. P95 latency targets for Pixel 9.


TL;DR

Chaining ML Kit’s barcode scanner into MediaPipe LLM Inference API on Pixel 9 hits a sub-700ms P95 latency budget — but only if you eliminate unnecessary thread hops between vision and language stages, throttle camera frames aggressively, and constrain LLM output to structured JSON. Skip any one of these and you ship a frustrating UX instead of a snappy one.


The architecture problem nobody talks about

What most teams get wrong about on-device vision-to-language pipelines: they treat the barcode scanner and the LLM as two independent modules, shuttling data between them through the main thread. The result is a latency profile that looks like this on a Pixel 9 with Tensor G4:

StageNaive (main-thread hops)Optimized (background dispatch)
Camera frame capture~16ms~16ms
ML Kit barcode decode~35ms~35ms
Main-thread context switches~80ms~0ms
LLM prompt construction~8ms~5ms
Time to first token (TTFT)~520ms~310ms
P95 end-to-end~720ms~410ms

That 300ms gap is the difference between a product that feels instant and one that feels broken.


Stage 1: Frame throttling the vision pipeline

CameraX’s ImageAnalysis will happily deliver frames at 30–60fps. Your barcode scanner does not need them. Set a non-blocking backpressure strategy and throttle to a practical ceiling — scanning more than 8–10 frames per second wastes GPU cycles and thermals on a decode that ML Kit will likely duplicate anyway.

val imageAnalysis = ImageAnalysis.Builder()
    .setTargetResolution(Size(1280, 720))
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()

var lastAnalyzedTimestamp = 0L
imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
    val now = System.currentTimeMillis()
    if (now - lastAnalyzedTimestamp >= 120L) { // ~8fps ceiling
        lastAnalyzedTimestamp = now
        processBarcodeFrame(imageProxy)
    } else {
        imageProxy.close()
    }
}

This cuts redundant ML Kit invocations under typical handheld scanning conditions, preserving the thermal headroom your LLM inference stage depends on.


Stage 2: Eliminating unnecessary context switches

The silent latency killer in vision-to-language pipelines is thread choreography, not raw compute. Barcode output from ML Kit is a string value — there is no GPU tensor to share — but naive implementations post results back to the main thread before dispatching to the LLM, accumulating 60–80ms of context-switch overhead that compounds at the P95 tail.

The fix: keep your BarcodeScanning result handling on a dedicated Dispatchers.Default coroutine, build your structured prompt string immediately in that context, and dispatch directly to LLM inference without any intermediate withContext(Main) hop.

suspend fun handleBarcodeResult(barcode: Barcode) = withContext(Dispatchers.Default) {
    val rawValue = barcode.rawValue ?: return@withContext
    val prompt = buildProductPrompt(rawValue) // stays off-main
    llmInference.generateResponseAsync(prompt, responseCallback)
}

fun buildProductPrompt(barcode: String): String = """
    You are a product data extractor. Given this barcode: $barcode
    Return ONLY valid JSON matching this schema:
    {"name": string, "category": string, "confidence": number}
    JSON:
""".trimIndent()

Enabling GPU delegation at LLM initialization is the other half of this stage — without it, inference falls back to CPU and the latency budget collapses entirely:

val options = LlmInference.LlmInferenceOptions.builder()
    .setModelPath(modelPath)
    .setMaxTokens(50)
    .setDelegate(LlmInference.LlmInferenceOptions.Delegate.GPU)
    .build()
val llmInference = LlmInference.createFromOptions(context, options)

Stage 3: Structured output constraints

The architecture decision most teams defer too long: JSON output constraints from day one, not as a polish step. Unconstrained LLM output forces a downstream parse-and-retry loop that blows your latency budget on the 10–15% of responses that deviate from schema.

MediaPipe LLM Inference API does not yet expose first-class grammar constraints the way llama.cpp does — so prompt engineering carries the full load. The schema injection pattern above (schema in prompt body + JSON: suffix) achieves consistent schema-valid first-attempt output on Gemma 2 2B with temperature ≤ 0.2.

ModelAvg TTFT (Pixel 9)Schema validityRecommended temp
Gemma 2 2B (INT4)~280ms~94%0.1–0.2
Gemma 2 2B (FP16)~480ms~96%0.1–0.2
Gemma 3 1B (INT4)~190ms~89%0.1 only*

* Gemma 3 1B exhibits a higher hallucination rate at this parameter scale; temperatures above 0.1 measurably increase schema deviation in structured output tasks.

Measured on Pixel 9 (Tensor G4), MediaPipe 0.10.x, cold-start excluded, 50-token output cap, ambient temperature, n=200 prompts per model.

For P95 targets, INT4 quantized Gemma 2 2B is the practical sweet spot on Pixel 9.


The P95 ceiling that matters

A user pointing a phone at a product barcode has approximately 600–800ms of patience before the UX registers as “slow”. The optimized pipeline — 8fps frame throttling, no unnecessary thread hops, INT4 Gemma 2 2B with GPU delegation — lands at roughly 410ms P95 on Pixel 9. That is your shipping target. Budget 290ms of headroom for UI rendering, network enrichment calls, or caching misses.


Three things to ship right

  1. Throttle frame analysis to 8–10fps. ML Kit barcode detection is fast, but redundant invocations compound thermal load and consume the GPU cycles your LLM depends on.

  2. Never post barcode results back to the main thread before LLM dispatch. Build your prompt string immediately in the decode callback context and dispatch directly to inference — the 60–80ms context-switch overhead is invisible in unit tests and brutal in P95 production traces.

  3. Enforce structured JSON output via prompt schema injection on day one. Set temperature ≤ 0.2, inject your target schema into the prompt, and cap output tokens at 50. This eliminates the parse-retry loop that silently destroys P95 latency before it ever reaches your metrics dashboard.


android kotlin mobile architecture multiplatform


Share: Twitter LinkedIn