| name | background-work-optimization |
| description | Schedule, batch, and constrain background work with WorkManager, BGTaskScheduler, and push-over-poll patterns to minimize battery and data cost. |
Background Work Optimization
Instructions
Background work is where undisciplined apps burn battery and wake radios. Modern iOS and Android both expect you to declare constraints and let the OS pick the best moment to run. This skill covers how to convert ad-hoc background work into OS-friendly, batched, constraint-aware jobs.
1. Choose the Right Primitive
Android:
| Need | Use |
|---|
| Deferred, guaranteed, constrained | WorkManager |
| Exact, wall-clock wake (rare) | AlarmManager.setExactAndAllowWhileIdle (only with user-visible UI) |
| Ongoing service user expects (music) | ForegroundService with proper foreground type |
| Data sync when idle + charging | WorkManager with requiresCharging(true) |
| Real-time updates | FCM / WebSocket kept alive by the OS, not polling |
iOS:
| Need | Use |
|---|
| Deferred data refresh | BGAppRefreshTaskRequest |
| Longer ML / sync job | BGProcessingTaskRequest |
| "Wake me when event happens" | Silent push (content-available: 1) + short handler |
| Background download | URLSession.background(withIdentifier:) |
| Ongoing (audio, nav, VoIP) | Background mode in Info.plist |
2. WorkManager Patterns (Android)
Register a unique periodic job — de-dup across reinstalls:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build()
val req = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
uniqueWorkName = "feed-sync",
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
request = req,
)
Rules:
- Never chain > 3 workers — chain explosions cause unclear failure modes.
- Use
CoroutineWorker for suspend functions; return Result.retry() on transient failures, Result.failure() on permanent ones.
- Avoid
ExpeditedWorkRequest for non-urgent work — it burns the app's foreground quota.
3. BGTaskScheduler Patterns (iOS)
Info.plist → BGTaskSchedulerPermittedIdentifiers:
<array>
<string>com.example.app.refresh</string>
<string>com.example.app.processing</string>
</array>
Register and schedule:
func registerHandlers() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.example.app.refresh",
using: nil
) { task in
self.handle(task: task as! BGAppRefreshTask)
}
}
func scheduleAppRefresh() {
let req = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
req.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
try? BGTaskScheduler.shared.submit(req)
}
func handle(task: BGAppRefreshTask) {
let op = SyncOperation()
task.expirationHandler = { op.cancel() }
op.completionBlock = { task.setTaskCompleted(success: !op.isCancelled) }
OperationQueue().addOperation(op)
scheduleAppRefresh()
}
4. Replace Polling with Push
Polling eats battery; push costs almost nothing when the OS manages the socket. Replace:
setInterval(fetchMessages, 30_000);
With an FCM / APNs silent push that triggers a short fetch only when something changed. For RN, use @react-native-firebase/messaging; for native, the platform SDK.
5. Batching Requests
Group small requests to reduce radio wake costs. One request / 30 s is ~10× worse than ten requests once every 5 min.
Pattern: coalesce by debouncing "dirty" state and flushing in one network round-trip:
private val pending = MutableSharedFlow<Change>(extraBufferCapacity = 64)
init {
pending
.buffer(Channel.UNLIMITED)
.debounce(500.milliseconds)
.onEach { changes -> api.flushBatch(changes) }
.launchIn(scope)
}
6. Respect Doze, App Standby, Low Power Mode
- Android: assume the device is in Doze at night. Do not rely on alarms that fire during Doze.
- iOS: Low Power Mode disables
BGAppRefresh. Observe NSProcessInfo.isLowPowerModeEnabled; pause optional animations, video prefetch, high-res downloads.
NotificationCenter.default.addObserver(
forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main
) { _ in
if ProcessInfo.processInfo.isLowPowerModeEnabled { reducePrefetch() }
}
7. Make Retries Cheap
Retries on failure must back off exponentially and respect Retry-After. A naïve retry storm after an outage is a frequent cause of "why did battery die after the server came back up?"
const delay = (attempt: number) => Math.min(2 ** attempt * 1000 + jitter(), 60_000);
8. Verify
- Android:
adb shell dumpsys jobscheduler shows scheduled, pending, and running jobs.
- iOS: Debugger → Simulate Background Fetch in Xcode; log
BGTaskScheduler.shared.pendingTaskRequests.
- Dashboards: rate of wakeups per user per hour; foreground CPU time; radio-on time.
Checklist