n4nAI

WorkManager for background LLM API calls in Android

Implement reliable Android WorkManager LLM API calls with Kotlin: background jobs, retries, constraints, and progress reporting for production apps.

n4n Team3 min read719 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  1. After enqueue, adb shell dumpsys jobscheduler shows a job for your package with the network constraint.
  2. Logcat prints Worker result: SUCCESS and your UI updates with the LLM output.
  3. Kill the app via adb shell am kill com.yourapp while the work is ENQUEUED. After restoring network, the job still runs.
  4. 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.

Tagsandroidworkmanagerbackground-jobsllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All kotlin & android llm integration posts →