MVP Factory
ai startup development

Wiring Android's WorkManager to a Quantized On-Device LLM for Background Summarization: Constraints, Expedited Tasks, and the Memory Ceiling That Determines Your Model Tier

KW
Krystian Wiewiór · · 5 min read

TL;DR

Running a quantized LLM inside WorkManager is not a drop-in job — it’s an architectural commitment. The memory ceiling on mid-range devices (~2 GB addressable per process) forces you to choose between INT4 and INT8 quantization before writing a single line of scheduling code. Doze mode will kill long-running inference unless you promote to a foreground service. Chained work sequences are the right primitive for chunked summarization. Get the model tier decision right first; everything else follows.


Why background LLM summarization is different

WorkManager’s execution model assumes Workers complete in seconds to low minutes — assumptions that break under 200 ms-per-token LLM inference. The failure modes are silent: OOM kills mid-inference, Doze-mode constraint violations that defer work by hours, and foreground-service promotion that confuses users with persistent notifications.

These examples assume llama.cpp via Android JNI as the inference runtime — the same path used by projects like llama.android and MLC LLM — though the scheduling patterns apply equally to MediaPipe’s LLM Inference API.

A typical mid-range device (Snapdragon 6 Gen 1, 6 GB RAM) allocates roughly 3.5 GB to the system and background processes, leaving your app process with a practical ceiling around 1.8–2.2 GB before the OOM killer becomes aggressive. That ceiling determines your entire model strategy.


The memory ceiling: INT4 vs INT8

Model Size (params)INT8 RAM (approx)INT4 RAM (approx)Safe on 6 GB device?
1B~1.0 GB~0.6 GBBoth tiers
3B~3.0 GB~1.7 GBINT4 only
7B~7.0 GB~4.0 GBNeither — INT4 footprint (~4 GB) exceeds available RAM on most 6 GB devices even with foreground promotion; treat 7B as server-side territory
1.5B (Phi-2 class)~1.5 GB~0.9 GBBoth with headroom

For background Workers without foreground promotion, target sub-1B INT4 or sub-1.5B INT4 models. Anything larger requires a foreground service, and anything at 7B or above should not be attempted on-device on current mid-range hardware. If you find yourself trying to rationalize a 7B model on a 6 GB device, that’s a signal to move the inference server-side, not to keep tuning constraints.


Scheduling the work: constraints that actually matter

WorkManager’s Constraints API gives you knobs that directly map to inference viability:

val inferenceConstraints = Constraints.Builder()
    .setRequiresBatteryNotLow(true)        // inference drains battery fast
    .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
    .build()

val summarizeRequest = OneTimeWorkRequestBuilder<SummarizationWorker>()
    .setConstraints(inferenceConstraints)
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .setInputData(workDataOf("chunk_index" to 0, "total_chunks" to 3))
    .build()

The setExpedited call is critical for user-triggered summarization. Expedited tasks bypass Doze-mode deferral and execute within minutes rather than hours, but they require a getForegroundInfo() override on your Worker — WorkManager will call it on older API levels to attach a notification.


Chained work for chunked summarization

What most teams get wrong about long-document summarization: they try to load the entire document in one Worker and blow the memory budget or hit the 10-minute execution window. The correct pattern is a chain of chunk Workers feeding a reduce Worker.

val chunkWorkers = (0 until totalChunks).map { index ->
    OneTimeWorkRequestBuilder<ChunkSummarizeWorker>()
        .setInputData(workDataOf("chunk" to index))
        .build()
}

val reduceRequest = OneTimeWorkRequestBuilder<ReduceSummaryWorker>().build()

WorkManager.getInstance(context)
    .beginWith(chunkWorkers)        // parallel fan-out
    .then(reduceRequest)            // serial reduce
    .enqueue()

Each ChunkSummarizeWorker loads the model, runs inference on a ~500-token window, unloads, and writes its partial summary to the output Data map. The ReduceSummaryWorker concatenates partials and optionally runs a second-pass summary. Model load/unload per chunk is expensive (~200–400 ms for INT4 1B models on a Snapdragon 6 Gen 1), but it keeps peak RSS below the OOM threshold.


Foreground service promotion for larger models

If your product requires a 3B model, you must promote the Worker to a foreground service. On CoroutineWorker, that means implementing getForegroundInfo():

override suspend fun getForegroundInfo(): ForegroundInfo {
    val notification = buildSummarizationNotification()
    return ForegroundInfo(NOTIFICATION_ID, notification,
        ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
}

FOREGROUND_SERVICE_TYPE_SHORT_SERVICE (API 34+) gives you up to 3 minutes of guaranteed execution without requiring a declared use-case permission — a practical sweet spot for 3B INT4 inference on a chunked document.


Conclusion

Three decisions determine whether this architecture ships or collapses in production.

First, select your model tier before writing scheduling code. Profile peak RSS on your minimum-spec device under load. If it exceeds 1.8 GB, you need either a smaller model or foreground service promotion — not a WorkManager tuning pass.

Second, use chained Workers for any document over ~1,500 tokens. Fan-out chunk Workers running in parallel, reduce with a serial terminal Worker. This keeps individual Worker memory bounded and works within WorkManager’s execution window.

Third, always set setExpedited for user-initiated summarization. Doze-mode deferral on background work can push execution by hours. Expedited tasks with a foreground notification keep latency in the seconds-to-minutes range and give users visible feedback that inference is running.


Share: Twitter LinkedIn