A Service is an app component for work without a UI. It runs on the main thread and creates no thread of its own. A started service runs until it is stopped, a bound one lives while clients are bound, and a foreground service is a started one with a visible notification.
Started: startService() → onCreate() → onStartCommand() on every start; it lives until stopSelf() or stopService(), then onDestroy().
Bound: bindService() → onBind() returns an IBinder the client calls methods on; the service is destroyed when the last client unbinds, unless it was also started.
Foreground: a started service promoted with startForeground() and an ongoing notification. The system treats the process as user-visible and rarely kills it; since Android 14 it needs a declared type.
Main thread: onStartCommand runs on the main thread, so blocking work there freezes the UI or causes an ANR. Start your own coroutine or thread and call stopSelf() when it ends.
**Service vs Thread: a thread is only execution; a service is a component the system tracks, so it raises the process priority and can be started by the system or other apps.
IntentService**: deprecated in API 30 with JobIntentService; deferrable work now goes to WorkManager.
AndroidMiddle
Background Work & Services
WorkManager, foreground services, service types, AlarmManager, Doze, background limits
Questions closed
0 / 22
- 0Review
- 5Asked often
Questions
WorkManager is the Jetpack API for deferrable work that must finish: it persists each request, runs it again after process death and reboot, and handles constraints and retries. It guarantees that the work eventually runs, never when.
What it gives you
The request is written to WorkManager's database on its background executor; the returned Operation completes once it is persisted (enqueue(request).await()). After that it survives app exit, process death and a reboot.
Constraints (network, charging, battery, storage), retries with backoff, unique names and chains are enforced by the library, not by your code.
Observable state from anywhere: getWorkInfoByIdFlow(id) reports ENQUEUED → RUNNING → SUCCEEDED, FAILED or CANCELLED.
What it does not promise
Timing. Doze, the App Standby bucket and system batching choose the moment; periodic work cannot run more often than every 15 minutes.
Running after a force stop. A force-stopped app runs nothing until the user opens it again, and some OEM battery managers block background work as well.
A deadline. Work whose constraints are never met stays ENQUEUED forever.
Work that is useless once the screen is gone belongs in viewModelScope; work that must happen at an exact time belongs to AlarmManager.
OneTimeWorkRequest runs once and ends in a final state; PeriodicWorkRequest repeats until it is cancelled. The period cannot go below 15 minutes (the floor of the platform job scheduler), and a shorter value is raised to 15 with only a log warning.
One-time: can be delayed with setInitialDelay, chained and expedited; it ends as SUCCEEDED, FAILED or CANCELLED.
Periodic: cannot be chained or expedited, and after every run it returns to ENQUEUED, so it never reports SUCCEEDED.
The floors: PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS is 15 minutes and MIN_PERIODIC_FLEX_MILLIS is 5 minutes. The limit exists for battery: every run can wake the CPU and the radio, and Doze stretches the interval further.
Flex: the four-argument builder (1, HOURS, 15, MINUTES) means "once an hour, somewhere in the last 15 minutes". A wide window lets the system batch your run with other apps'; it is not a way to get punctuality.
If product needs five minutes: while the screen is open, poll from the screen with a coroutine; when the server knows that data changed, let it send an FCM message that enqueues one-time work.
Input goes in with setInputData(workDataOf(...)) and the result comes back with Result.success(workDataOf(...)). Data is stored in WorkManager's database and capped at 10 240 bytes, so a 2 MB image throws IllegalStateException; pass a file path, a URI or a row id instead.
Types: only primitives, String and arrays of them; there is no Parcelable or object support.
Output: read from WorkInfo.outputData once the work is SUCCEEDED; in a chain it automatically becomes the next Worker's input. The same 10 KB cap applies.
Progress: setProgress(...) in a CoroutineWorker (setProgressAsync in a Worker) is visible in WorkInfo.progress only while the work is RUNNING, so it cannot carry the result.
Why the cap: Data is serialized into the request's row and read back on every retry. It is a pointer, not a payload.
What to pass instead: write the bytes to filesDir or cacheDir and pass the path, insert a Room row and pass its id, or pass a content:// URI. For a URI from ACTION_OPEN_DOCUMENT or the photo picker call takePersistableUriPermission first; for a URI from a share intent the grant dies with the receiving component, so copy the content into filesDir and pass the path. Otherwise the Worker gets a SecurityException when it finally runs hours later.
Ask two things: must the work survive the user leaving the app, and must it run at an exact moment or while the user watches? Screen-bound work is a coroutine, deferrable work that must finish is WorkManager, ongoing visible work is a foreground service, and an exact time is AlarmManager.
**Coroutine in viewModelScope: the result matters only while the screen is open: search, loading a list. It is cancelled with the ViewModel.
Application-scoped coroutine: short work that should outlive the screen, such as finishing a save after Back, where losing it on process death is acceptable.
WorkManager: sync, uploads, log flushing, cleanup: must finish, can wait for network or charging, needs retries.
Expedited work: the user just asked and waits seconds to a few minutes; it draws on a per-app quota.
Foreground service**: the user knows it is running and it is ongoing: music, navigation, a call, workout tracking. It needs a type and a notification. For a large user-started transfer on Android 14+, a user-initiated data transfer job (JobInfo.Builder.setUserInitiated(true)) is the intended API.
**AlarmManager: an exact moment: an alarm clock or a calendar reminder. It only wakes the app; the work itself then goes to one of the tools above.
FCM**: when the server knows something changed, push instead of polling and enqueue work from the message.
JobScheduler directly is rarely needed: WorkManager is built on it.
Almost every line: syncBlocking runs on the main thread, startService() while the app is in the background throws IllegalStateException on Android 8+, and START_STICKY is not durability. A nightly sync is periodic WorkManager work.
Main thread: onStartCommand runs on the main thread, so the blocking sync freezes the UI or causes an ANR. The service also never calls stopSelf(), so it would live until the system kills it.
Background start: a service started while the app has no visible UI and is not on a short broadcast allowlist gets IllegalStateException on Android 8+. An alarm broadcast only buys a few seconds, far less than a sync needs. Since Android 12 startForegroundService() is blocked from the background too, apart from exempt cases.
Return values: START_NOT_STICKY means the service is not recreated after the process dies; START_STICKY recreates it with a null intent; START_REDELIVER_INTENT recreates it with the last intent. None of them remembers progress or promises when the restart happens.
Binding for progress: a binder is IPC machinery and a bound service is a client-server channel. Inside one process, a repository exposing a StateFlow, or a Room table the worker writes to, is simpler and survives the Activity.
The fix: a PeriodicWorkRequest of 24 hours with UNMETERED and battery-not-low constraints, enqueued as unique work with ExistingPeriodicWorkPolicy.UPDATE.
A bound service still makes sense when another process is the client (AIDL, Messenger) or when the platform asks you to subclass one: MediaSessionService, TileService.
Every request is a row in WorkManager's own Room database, and schedulers hand it to the platform JobScheduler or run it in-process while the app is alive. On each start and after boot WorkManager reconciles its database with the system, so a restart loses nothing.
Storage
enqueue() hands a WorkSpec (Worker class, input Data, constraints, backoff, state, attempt count) to a background executor that writes it; the returned Operation reports when that is done.
Finished work is kept for about a day so observers can read the result, then pruned.
Scheduling and execution
SystemJobScheduler registers each request with JobScheduler through SystemJobService; on old API levels without JobScheduler WorkManager used AlarmManager.
GreedyScheduler runs work inside the current process as soon as its constraints hold while the app is alive, without a round-trip through the system.
A Worker runs on Configuration.executor, a CoroutineWorker on Dispatchers.Default. WorkManager also caps how many jobs it registers at once (setMaxSchedulerLimit) to stay under the platform's per-app job limit.
Surviving restarts
RescheduleReceiver listens for BOOT_COMPLETED and clock or time-zone changes; on the next start ForceStopRunnable notices that the app was force-stopped and registers its jobs again.
The consequence: after a force stop nothing runs until the user opens the app, because the app cannot run code to reschedule.
Stopping is cooperative. WorkManager sets isStopped and calls onStopped(), but nothing interrupts the pool thread that runs a blocking doWork(), so a loop that never checks isStopped copies to the end, and the Result it returns is then thrown away.
The flavours
ListenableWorker is the base class: startWork() returns a ListenableFuture<Result> and you own the threading.
Worker: a blocking doWork() on a thread from Configuration.executor; you must poll isStopped.
CoroutineWorker: a suspend fun doWork() on Dispatchers.Default. A stop cancels the coroutine, every suspension point throws CancellationException and finally blocks run. This is the default choice in Kotlin; wrap blocking I/O in withContext(Dispatchers.IO).
RxWorker returns a Single<Result>; a stop disposes the subscription.
What a stop means
Reasons: a constraint was lost, the work was cancelled, or it hit the roughly ten-minute execution limit. stopReason tells you which on API 31+.
Unless it was cancelled, the work goes back to ENQUEUED and restarts from the top, so commit progress per item.
onStopped() can be called on any thread and must stay lightweight; set a flag or cancel something, never block or do I/O in it.
In a CoroutineWorker, a broad catch (e: Exception) swallows the CancellationException and keeps the work going; catch specific exceptions.
doWork() starts the uploads in a scope of its own and returns Result.success() at once, so WorkManager marks the work SUCCEEDED and stops tracking it. The process can then be killed mid-upload, and nothing ever retries the lost files.
Why it happens: the returned Result is the contract: the work is finished the moment doWork() returns. WorkManager releases its wakelock, and the coroutine in CoroutineScope(Dispatchers.IO) belongs to nobody, so no constraint, stop or retry logic covers it, and a process with no visible screen and no running job is among the first the system kills.
Errors are lost too: an IOException inside that launch never becomes Result.retry(); it is an uncaught exception in a root coroutine and crashes the app.
How the fix works: a CoroutineWorker has a suspend fun doWork(), and withContext(Dispatchers.IO) suspends until every file is sent, so the result reflects what really happened and a stop cancels the uploads. Wrap the loop in try/catch (e: IOException) returning Result.retry() to survive a network blip.
Callback APIs: the same bug appears with call.enqueue { } or a listener; bridge it with suspendCancellableCoroutine, or extend ListenableWorker and complete the future with CallbackToFutureAdapter.
Catch it next time: any launch, GlobalScope or callback inside doWork() is a review red flag; in a test, run TestListenableWorkerBuilder<UploadWorker>(context).build().doWork() against a fake API and assert every file was sent by the time it returns.
Constraints are conditions (network type, charging, battery or storage not low) that must hold before the work starts and are watched for the whole run. If one is lost mid-run, WorkManager stops the Worker, returns the work to ENQUEUED and later restarts it from the beginning.
The constraints
setRequiredNetworkType(...): CONNECTED, UNMETERED, NOT_ROAMING, METERED, TEMPORARILY_UNMETERED or NOT_REQUIRED.
setRequiresCharging, setRequiresBatteryNotLow, setRequiresStorageNotLow, and setRequiresDeviceIdle on API 23+.
addContentUriTrigger(uri, triggerForDescendants) runs work when a content:// tree changes.
Traps
Restarted, not resumed. The Result returned after a stop is discarded, so a half-finished upload is redone unless the Worker records progress per item.
**CONNECTED is not "server reachable".** A captive portal satisfies it, so keep the IOException → Result.retry() path.
No expiry. Work that requires charging on a phone nobody charges waits forever; decide inside doWork() when the job is stale.
The stop reason is in stopReason on API 31+, for example STOP_REASON_CONSTRAINT_CONNECTIVITY.
Plain enqueue() adds a new independent request on every app start, and ExistingPeriodicWorkPolicy.KEEP ignores the new request while the old schedule exists, so the interval from the first install wins forever. Use unique one-time work with KEEP and unique periodic work with UPDATE.
ExistingWorkPolicy for one-time work
KEEP: if work with that name is queued or running, drop the new request. Right for "sync on app start".
REPLACE: cancel the existing work, including a run in progress, and enqueue the new one.
APPEND: chain the new request after the existing one; if the predecessor failed or was cancelled, the new one fails too.
APPEND_OR_REPLACE: like APPEND, but a dead predecessor is replaced instead of poisoning the queue. Right for an outbox.
ExistingPeriodicWorkPolicy
KEEP: keep the old schedule; this is the stale-interval bug.
UPDATE: swap in the new spec and keep the original enqueue time, so the next run is recomputed from it with the new period (WorkManager 2.8+). The right default for a schedule you declare on every start.
CANCEL_AND_REENQUEUE is the old REPLACE: it cancels a running instance and restarts the schedule, so calling it on every launch kills in-flight syncs.
Returning Result.retry() reschedules the work after a backoff delay, and there is no attempt limit: only your code stops the loop. Count attempts with runAttemptCount and return Result.failure() once the budget is spent or the error is permanent.
How backoff behaves
The default is BackoffPolicy.EXPONENTIAL from 30 seconds; LINEAR grows by the base delay per attempt. Set it with setBackoffCriteria(...).
The delay is clamped between WorkRequest.MIN_BACKOFF_MILLIS (10 seconds) and MAX_BACKOFF_MILLIS (5 hours). After about ten attempts an endless retry wakes the device roughly five times a day, forever.
runAttemptCount is 0 on the first run, so runAttemptCount >= 5 means the sixth attempt.
The delay is a minimum, not a schedule: constraints and Doze still apply on top.
Classify every failure
Transient: timeout, IOException, HTTP 5xx or 429: Result.retry(), bounded.
Permanent: 400, 401, 404, a validation error: Result.failure() at once; a retry gets the same answer.
Uncaught exception: it is not a retry: the work ends as FAILED, so a Worker without a try loses the job on the first network blip.
When you give up, mark the item as failed in your database so the UI can show it and offer a manual retry.
beginWith(listOf(a, b, c)).then(publish).enqueue(): a list in beginWith runs in parallel, then waits for all of them, and publish starts only when all three succeed. If one returns Result.failure(), everything downstream is marked FAILED and publish never runs.
The building blocks
beginWith(...) returns a WorkContinuation; then(...) adds the next stage; nothing is scheduled until enqueue().
beginUniqueWork(name, policy, work) gives the whole chain a unique name.
WorkContinuation.combine(listOf(chainA, chainB)) joins independent chains into one next step.
Only one-time work can be chained; periodic work cannot.
What people get wrong
Failure and cancellation spread downstream, and there is no "continue on error" flag. If a step is optional, catch inside it and return Result.success() with a marker in the output.
**Result.retry() is not failure. That node retries with its own backoff and the rest of the chain waits.
Outputs collide.** With several parents, the default OverwritingInputMerger keeps one value per key; ArrayCreatingInputMerger collects them into arrays, which is what a fan-in usually wants.
A chain is not a transaction. Each step can be restarted on its own, so each must be idempotent.
Inside the Worker call setProgress(workDataOf(...)) and observe WorkInfo.progress through a Flow keyed by a unique work name. Work longer than about ten minutes, or that the user should see, calls setForeground() to run in a foreground service with a notification.
Progress
setProgress (or setProgressAsync in a Worker) is visible only while the work is RUNNING. It is empty again after a retry or a restart after process death.
Observe getWorkInfosForUniqueWorkFlow(name) in the ViewModel. A constant name can be re-attached after process death; a UUID kept in memory cannot.
Progress the user must still see later ("12 of 40 sent", "3 failed") goes to Room, written by the Worker per item. WorkInfo then only answers "is the job still alive?".
Long-running work
setForeground(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) moves the work into WorkManager's SystemForegroundService and lifts the ten-minute limit.
On Android 14+ declare that service in your manifest with the matching foregroundServiceType (merged with tools:node="merge") and add the type permission, for example FOREGROUND_SERVICE_DATA_SYNC.
Call it early in doWork(). On Android 12+ it can fail when the app is in the background, and on Android 15 dataSync is limited to six hours a day.
setExpedited asks the system to start a one-time request as soon as possible, paid from a limited per-app quota. OutOfQuotaPolicy decides what happens when that quota is empty: run it as ordinary work or drop it.
What it is
One-time requests only: a PeriodicWorkRequest cannot be expedited, and an expedited request with setInitialDelay throws.
On API 31+ it becomes a JobScheduler expedited job: it usually starts within seconds, keeps network access and is less affected by Doze and Battery Saver.
Below API 31 WorkManager runs it in a foreground service instead, so you must override getForegroundInfo() (the default throws) and return a notification with a matching type.
The quota follows the App Standby bucket: a foreground app effectively has no limit, an app in RARE has almost nothing.
When the quota is gone
RUN_AS_NON_EXPEDITED_WORK_REQUEST turns it into ordinary work. Right for almost everything: it still happens, just later.
DROP_WORK_REQUEST throws it away. Only for work that is worthless unless immediate.
The trap is marking every request expedited: the quota is shared, so the eager requests starve the one that mattered. Expedited is for something the user just asked for and is waiting on; work the user watches for minutes is setForeground or a foreground service.
Android 12 stopped apps from starting a foreground service from the background, apart from exempt cases. Android 14 made a declared type mandatory, with a matching permission and the same type in startForeground. Android 15 added daily time limits for dataSync and mediaProcessing.
Three places that must agree (Android 14+)
Manifest: android:foregroundServiceType="dataSync" on the <service>; without it startForeground throws MissingForegroundServiceTypeException.
Permissions: FOREGROUND_SERVICE plus the type permission, such as FOREGROUND_SERVICE_DATA_SYNC. Some types also need a runtime permission, for example location or camera.
The call: ServiceCompat.startForeground(service, id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC); a type the manifest does not declare throws.
Starting and timing
After startForegroundService() you have about five seconds to call startForeground(), or the app crashes with ForegroundServiceDidNotStartInTimeException.
From the background (Android 12+) the start throws ForegroundServiceStartNotAllowedException unless an exemption applies: a visible activity, a high-priority FCM message, an exact alarm, some Bluetooth or geofence callbacks.
Android 15: dataSync and mediaProcessing get six hours per 24; then Service.onTimeout() is called and you must stop. shortService gets about three minutes. A BOOT_COMPLETED receiver can no longer start foreground services of type dataSync, camera, mediaPlayback, phoneCall, mediaProjection or microphone.
Doze defers jobs, alarms and network access on an idle, unplugged device into short maintenance windows; App Standby buckets ration jobs and alarms per app by how recently the user used it.
Doze (Android 6, lighter variant in 7)
Starts when the screen is off and the device is unplugged; maintenance windows come further and further apart.
Between windows: no network, wake locks ignored, jobs and ordinary alarms wait.
What gets through: setExactAndAllowWhileIdle and setAndAllowWhileIdle (rate-limited), setAlarmClock, high-priority FCM messages; expedited jobs are less affected.
App Standby buckets (Android 9)
ACTIVE, WORKING_SET, FREQUENT, RARE and RESTRICTED, assigned from real usage. The lower the bucket, the fewer job and alarm runs; RESTRICTED gets roughly one window a day.
UsageStatsManager.getAppStandbyBucket() tells you where you are. An app opened twice a month will not sync hourly, and that is the system working correctly.
Limits by release
8.0: no startService() from the background; most implicit broadcasts are no longer delivered to manifest receivers; background location is throttled.
10: no starting activities from the background.
12: foreground-service starts from the background are blocked; exact alarms need a permission.
14: foreground-service types are mandatory; exact alarms are no longer pre-granted.
15: time limits for dataSync and mediaProcessing. Details are in the foreground-service question.
startForeground() is called only after a network request, but a service launched with startForegroundService() must promote itself within about five seconds. On a slow network the deadline passes first and the system crashes the app with ForegroundServiceDidNotStartInTimeException.
Why it happens: the clock starts at startForegroundService(), not when the service is ready. scope.launch returns at once, so onStartCommand ends without a promotion, and the call inside the coroutine waits for the HTTP response. On office Wi-Fi it lands in time; on a weak network, or behind a slow cold start, it does not.
What the user sees: on Android 12+ a crash with "Context.startForegroundService() did not then call Service.startForeground()"; older versions throw RemoteServiceException.
How the fix works: promote synchronously as the first statement of onStartCommand, with a placeholder notification, then update that notification through NotificationManager.notify with the same id once the data arrives.
Every path counts: a service that hits an error and calls stopSelf() before startForeground() crashes the same way, so promote first and stop afterwards.
Catch it next time: no lint flags it: test with emulator network throttling or a fake repository that adds delay(10_000), and watch Play Console vitals for this exception after a release.
Schedule an exact alarm with setAlarmClock, hold the exact-alarm permission, and rebuild every alarm from your own database in a boot receiver: AlarmManager forgets all alarms on reboot and app update.
The permission
SCHEDULE_EXACT_ALARM (API 31+) is special access. On Android 14+ it is no longer pre-granted to newly installed apps that target Android 13 or higher: check canScheduleExactAlarms() and send the user to Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM. Revoking it cancels your pending exact alarms, so listen for ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED.
USE_EXACT_ALARM (API 33+) is granted at install, but Play allows it only when alarms or calendar events are the app's core function. An alarm clock qualifies; a shop reminder does not.
Getting through Doze
setExact is deferred while the device dozes.
setExactAndAllowWhileIdle fires in Doze, but at most about once every nine minutes per app.
setAlarmClock is the right choice: exempt from Doze, it wakes the device slightly early and shows the alarm icon in the status bar. Its second PendingIntent opens the screen when the user taps that icon.
Surviving the reboot
Store the schedule in your database, declare RECEIVE_BOOT_COMPLETED, and re-arm the next alarm on ACTION_BOOT_COMPLETED, ACTION_MY_PACKAGE_REPLACED and each time an alarm fires. AlarmManager is a single wake-up call; your database is the schedule.
Direct Boot: after an overnight reboot the phone is still locked, and BOOT_COMPLETED is not delivered until the first unlock. Also handle ACTION_LOCKED_BOOT_COMPLETED, set android:directBootAware="true" on the receiver and keep the alarm schedule in device-protected storage (createDeviceProtectedStorageContext()).
On top of Doze and App Standby, many vendors ship their own battery managers that freeze or kill apps outside an allowlist (jobs and alarms included), and no API or targetSdk opts you out. You design the feature so it does not depend on background runs.
What is happening
Vendor battery managers: Xiaomi HyperOS, Samsung's sleeping apps, ColorOS and others stop apps soon after the screen goes off, or after a swipe from recents. None of it is AOSP behavior, so it differs per vendor and version; dontkillmyapp.com tracks the settings.
User restriction: "Restricted" battery usage makes ActivityManager.isBackgroundRestricted() true, and the app gets nothing until it is opened again.
Low bucket: a rarely opened app sits in RARE or RESTRICTED and gets about one window a day, on every phone.
What you can do
Make background work an optimization, not the mechanism: also sync when the app comes to the foreground, and keep the sync idempotent and resumable.
Let the server push with FCM when something must happen now, instead of polling on a schedule that will not run.
Detect the state and, at a moment when the feature obviously needs it, show one screen that explains and deep-links to the vendor setting. Play allows REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only for apps whose core function needs it, so do not request it by default.
Measure it: log scheduled versus actual run times with the manufacturer, the standby bucket and isIgnoringBatteryOptimizations, and argue from data rather than one tester's phone.
Treat the push as a wake-up signal, not as the work: onMessageReceived enqueues a unique WorkManager job and returns, and only messages that really cannot wait are sent with high priority.
The mechanics
A high priority message wakes the device from Doze and briefly lets the app use the network and start a foreground service. A normal one waits for the next maintenance window, which is right for most traffic.
Send data-only messages for a sync (see the FCM message-types question).
onMessageReceived gets only seconds before the process may be killed. Enqueue the work and return.
enqueueUniqueWork with ExistingWorkPolicy.KEEP turns a burst of ten pushes into one sync; for collapse_key see the same question.
The budget you are spending
FCM downgrades high-priority messages that do not lead to a user-visible notification. Compare RemoteMessage.priority with originalPriority to see it happen.
Expedited work draws on the same per-app quota as all other expedited requests, and the quota shrinks with the standby bucket.
The push carries a version or a cursor, never the payload; the client fetches and reconciles, so a lost message costs nothing. Delivery is best effort, so the same sync also runs on foreground and on a periodic schedule.
Ask the scheduler directly: adb shell dumpsys jobscheduler shows which constraints are unmet and when the job may run, and WorkManager's diagnostics broadcast logs its own view of the work. Then reproduce Doze and standby on purpose, and add telemetry that answers the question without the device.
On a device you have
dumpsys jobscheduler, filtered by your package: the job's required and satisfied constraints, backoff, standby bucket and earliest run time. Usually this alone gives the answer.
am broadcast -a "androidx.work.diagnostics.REQUEST_DIAGNOSTICS" -p <package> makes WorkManager log its completed, scheduled and running work for the last 24 hours, with attempt counts.
Android Studio's Background Task Inspector shows the same live, including chains and output data.
cmd jobscheduler run -f <package> <jobId> runs the job now, which separates "never scheduled" from "scheduled and deferred".
Reproduce the conditions
dumpsys battery unplug, then dumpsys deviceidle force-idle to enter Doze and dumpsys deviceidle step to move through its states.
am set-standby-bucket <package> restricted puts the app into the worst bucket.
Restore with dumpsys deviceidle unforce, dumpsys battery reset and the active bucket.
In production
A job that never ran produces no crash and no log line, so you have to measure the absence. Log the enqueue, the first run and runAttemptCount from inside the Worker, report the gap with the manufacturer and standby bucket, and alert on a missing completion rather than on an error.