Running an android workmanager llm api integration looks easy until the phone enters Doze mode, the app gets killed, or the model endpoint returns a 429. This guide lays out a concrete pattern for queuing LLM completion requests from a background worker, surviving process death, and surfacing results to the UI without hacky foreground services. We’ll use Kotlin, the modern WorkManager 2.9+ API, and a standard OpenAI-compatible HTTP client.
Step 1: Add dependencies and initialize WorkManager
WorkManager is the only Android Jetpack component that guarantees execution after the app exits, respecting system health constraints. Start by declaring the runtime and a networking stack in your module-level build.gradle.kts:
dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
If you target SDK 34+, you do not need a custom Application class to initialize WorkManager—the default initializer is sufficient. If you need custom logging or a custom WorkerFactory for dependency injection, implement Configuration.Provider:
class App : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(Log.INFO)
.build()
}
Do not use RxWorker unless you are already deep in RxJava. CoroutineWorker maps cleanly to Retrofit’s suspend functions and avoids callback hell.
Step 2: Define the LLM API client
Most inference gateways expose an OpenAI-compatible /v1/chat/completions shape. Define the request/response contracts with Moshi or kotlinx.serialization:
data class Message(val role: String, val content: String)
data class ChatRequest(
val model: String,
val messages: List<Message>,
val max_tokens: Int = 512
)
data class Choice(val message: Message)
data class ChatResponse(val choices: List<Choice>)
interface LLMService {
@POST("v1/chat/completions")
suspend fun complete(@Body req: ChatRequest): ChatResponse
}
Build the client lazily inside the worker or via DI. The base URL should point to your gateway. If you route through n4n.ai, a single OpenAI-compatible endpoint covers 240+ models and automatically falls back when a provider is degraded, so your worker can treat intermittent 5xx as retryable instead of fatal.
val retrofit = Retrofit.Builder()
.baseUrl("https://api.n4n.ai/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
val service = retrofit.create(LLMService::class.java)
Keep the model name and prompt out of the compiled layout. Pass them as workDataOf inputs so the same worker serves multiple use cases.
Step 3: Implement the Worker with retry policy
A CoroutineWorker gives you a suspend doWork() where you perform the network call and return Result. The critical decision is mapping exceptions to Result.retry() vs Result.failure(). Rate limits (429) and transport errors are transient; auth errors (401) are not.
class LLMWorker(ctx: Context, params: WorkerParameters) :
CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val prompt = inputData.getString("prompt") ?: return Result.failure()
val model = inputData.getString("model") ?: "gpt-4o-mini"
val service = Retrofit.Builder()
.baseUrl("https://api.n4n.ai/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(LLMService::class.java)
return try {
val resp = service.complete(
ChatRequest(model, listOf(Message("user", prompt)))
)
val output = resp.choices.firstOrNull()?.message?.content ?: ""
Result.success(workDataOf("result" to output))
} catch (e: retrofit2.HttpException) {
when (e.code()) {
429 -> Result.retry()
in 500..599 -> Result.retry()
else -> Result.failure()
}
} catch (e: java.io.IOException) {
Result.retry() // airplane mode, DNS failure, socket timeout
}
}
}
Never block the coroutine with .execute(). If you need a timeout shorter than the default 10 seconds, configure an OkHttp Interceptor with callTimeout. WorkManager already applies its own backoff, so don’t wrap the call in another retry loop.
Step 4: Enqueue unique work with constraints
Background LLM calls should only run when the device has network and is not in a critical battery state. Use Constraints and enqueueUniqueWork to prevent duplicate summarization jobs when the user triggers the action multiple times:
val request = OneTimeWorkRequestBuilder<LLMWorker>()
.setInputData(workDataOf(
"prompt" to "Summarize the following: ${longText}",
"model" to "claude-3-haiku"
))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
10_000, // 10s initial backoff, doubles each retry
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("llm-summary", ExistingWorkPolicy.REPLACE, request)
ExistingWorkPolicy.REPLACE cancels any prior enqueued instance and starts fresh. If the job is idempotent and you’d rather queue, use APPEND_OR_REPLACE. The work ID is stable across process death—store it in your ViewModel if you need to observe later.
Step 5: Observe progress and output in the UI
WorkManager emits WorkInfo through LiveData or getWorkInfoByIdFlow(). In a Fragment or Compose screen, collect the state and react:
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(request.id)
.observe(viewLifecycleOwner) { info ->
if (info == null) return@observe
when (info.state) {
WorkInfo.State.ENQUEUED -> showLoading()
WorkInfo.State.RUNNING -> showLoading()
WorkInfo.State.SUCCEEDED -> {
val result = info.outputData.getString("result")
showSummary(result)
}
WorkInfo.State.FAILED -> showError()
WorkInfo.State.BLOCKED -> {} // waiting on constraints
else -> {}
}
}
For Compose, use androidx.work.WorkManager’s getWorkInfoByIdFlow with collectAsStateWithLifecycle. Do not poll a local database from a background thread to guess completion—WorkManager is the source of truth for scheduling state.
Step 6: Handle rate limits, caching, and metering
LLM endpoints often support cache hints. If your prompt is static (e.g., a system prompt), send Cache-Control so the gateway or provider can reuse a prefix cache:
interface LLMService {
@Headers("Cache-Control: max-age=300")
@POST("v1/chat/completions")
suspend fun completeCached(@Body req: ChatRequest): ChatResponse
}
Gateways like n4n.ai forward provider cache-control hints and expose per-token usage metering, letting you attribute background job cost without custom instrumentation. Read response headers in an OkHttp Interceptor and log x-token-usage to your analytics. This matters because a background worker that fires every hour can silently burn tokens if a loop regenerates identical embeddings.
Also set a realistic max_tokens cap. A background worker should not request 8k tokens for a summary—keep it bounded so a slow connection does not hold the wake lock longer than necessary.
Step 7: Verify the integration on a device
Emulators lie about Doze. Test on a real device or force the conditions via adb:
# Simulate unplugged, idle device
adb shell dumpsys battery unplug
adb shell am set-inactive com.yourapp true
# Trigger your work enqueue from the app UI or via broadcast
adb shell am broadcast -a android.intent.action.BOOT_COMPLETED
# Watch worker execution
adb logcat -s androidx.work.* *:S
Success criteria:
- After enqueue,
adb shell dumpsys jobschedulershows a job for your package with the network constraint. - Logcat prints
Worker result: SUCCESSand your UI updates with the LLM output. - Kill the app via
adb shell am kill com.yourappwhile the work isENQUEUED. After restoring network, the job still runs. - Force a 429 by temporarily pointing the base URL to a mock that returns 429; confirm the job retries with increasing delay and eventually succeeds or fails cleanly without crashing.
If all four hold, your android workmanager llm api pipeline is production-ready. The pattern scales to periodic sync by swapping OneTimeWorkRequestBuilder for PeriodicWorkRequestBuilder with a minimum 15-minute interval, and it composes with WorkContinuation if you need to pre-process text before the model call.