Background Jobs and Scheduling Across Mobile OS Constraints

Capítulo 12

Estimated reading time: 13 minutes

+ Exercise

Why Background Jobs Matter in Offline-First Apps

Offline-first apps rely on work that should happen even when the user is not actively staring at the screen: uploading photos taken minutes ago, reconciling local changes after connectivity returns, refreshing reference data before a shift starts, or cleaning up local storage to stay within device limits. This work is typically expressed as background jobs: units of work scheduled to run later, potentially under constraints (network available, device charging, idle, unmetered network) and with OS-enforced limits (execution time caps, throttling, batching, and app standby rules).

Modern mobile operating systems treat background execution as a scarce resource. They prioritize battery life, thermal limits, and user privacy. As a result, “run a timer every 5 minutes” is not a reliable strategy. Instead, you need to model background work as opportunistic and constraint-driven: the OS decides the exact time, and your app must be correct even if a job runs late, runs less frequently than you want, or is cancelled and rescheduled.

Core Concepts: Jobs, Tasks, and Constraints

Job vs. long-running service

A background job is a discrete task with a clear start and end: “upload pending attachments,” “refresh catalog,” “compact database,” “rebuild search index.” A long-running background service is an always-on process. On iOS, always-on is generally not allowed except for specific modes (audio, navigation, VoIP, etc.). On Android, long-running background work must typically be a foreground service with a persistent notification, and even that is restricted in newer versions.

For offline-first apps, prefer jobs over services. Jobs are schedulable, can be constrained, and align with OS power management. If you truly need continuous work (e.g., turn-by-turn navigation), treat it as a special feature with explicit UX and OS-specific handling.

Constraints

Constraints describe when a job is allowed to run. Common constraints include:

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

  • Network type: any network, unmetered only, or no network required.

  • Charging: only when charging (useful for heavy uploads or compaction).

  • Idle: only when device is idle (rarely available on iOS; limited on Android).

  • Battery not low: avoid running when battery is critically low.

  • Storage not low: avoid work that increases disk usage when storage is constrained.

Constraints are not guarantees. They are eligibility rules. Even if constraints are satisfied, the OS may still delay execution.

One-off vs. periodic

One-off jobs run once, typically triggered by an event (user action, connectivity regained, push notification). Periodic jobs run repeatedly, but on mobile OSes the interval is best-effort. Periodic work should be used for “maintenance” and “refresh” tasks where exact timing is not critical.

Deadlines and backoff

Some schedulers let you specify a window (earliest start, latest deadline). Use deadlines sparingly; forcing deadlines can increase battery usage and may still not be honored under extreme conditions. Backoff policies control how rescheduling happens after failures; in practice, you should assume the OS may add its own throttling on top.

OS Constraints You Must Design Around

iOS: Background execution is limited and mode-based

iOS offers several mechanisms, each with strict rules:

  • BGTaskScheduler (iOS 13+): schedules background app refresh tasks and background processing tasks. The system decides when to run them. Processing tasks can request more time and can require external power and network, but still are not immediate.

  • Background fetch (legacy): opportunistic fetch; less commonly relied upon now.

  • Silent push notifications: can wake the app briefly to process content, but delivery is not guaranteed and is subject to throttling and user/device settings.

  • Background URLSession: for uploads/downloads that should continue even if the app is suspended; the system performs transfers and wakes the app to handle completion events.

Key constraints: limited execution time when woken, unpredictable scheduling, and strict entitlement requirements for special background modes.

Android: Doze, App Standby, and foreground service restrictions

Android provides:

  • WorkManager: the recommended API for deferrable background work with constraints; it uses JobScheduler/AlarmManager under the hood depending on API level.

  • JobScheduler: system scheduler for jobs; constraints-based; batches work for efficiency.

  • Foreground services: for user-visible ongoing work; requires a persistent notification; increasingly restricted for background starts.

  • Exact alarms: heavily restricted; should be reserved for user-facing time-critical events (alarms, calendars) and may require special permission.

Key constraints: Doze mode defers network and jobs when the device is idle; App Standby buckets throttle background work for infrequently used apps; background execution limits prevent starting services freely.

Cross-platform reality: “best effort” scheduling

Across both OSes, the practical rule is: if your app’s correctness depends on a job running at an exact time, you need an alternative. Alternatives include: doing the work when the user opens the app, using push to prompt a sync attempt, or using OS-supported transfer mechanisms (e.g., background URLSession) that are more reliable than arbitrary code execution.

Designing Background Work for Offline-First Correctness

Make jobs idempotent and restartable

A background job can be interrupted at any time: the OS may suspend the app, kill the process, or revoke time. Therefore, each job should be restartable without manual cleanup. Practically, that means:

  • Persist job state (e.g., “last processed local row id,” “next page token,” “pending file list”).

  • Process in small chunks and checkpoint progress frequently.

  • Assume the same job may run twice; it should not corrupt data or duplicate side effects.

This chapter does not re-teach retry strategies or operation queue design; instead, focus on the scheduling layer: the scheduler may re-run work, and your job handler must tolerate that.

Separate “eligibility” from “execution”

Eligibility answers: “Should we schedule something?” Execution answers: “We are running now; what exactly do we do?” Keep these separate so you can schedule conservatively but execute precisely.

Example: you might schedule a “sync maintenance” job whenever there is any pending work, but when it runs it should quickly decide whether there is still work and exit fast if not. This avoids wasted battery while still giving the OS a chance to run you when conditions are good.

Prefer event-driven triggers

Instead of periodic polling, schedule one-off jobs in response to events:

  • User creates or edits data (schedule an upload job).

  • Connectivity changes to “online” (schedule a reconciliation job).

  • Push notification indicates server-side changes (schedule a refresh job).

  • App enters background (schedule a short “flush” job if the OS allows).

Event-driven scheduling reduces unnecessary wakeups and aligns with OS batching.

Practical Step-by-Step: Android Scheduling with WorkManager

WorkManager is the default choice for deferrable background work on Android because it handles API differences, persists work across reboots, and supports constraints.

Step 1: Define the work unit

Create a Worker (or CoroutineWorker) that performs a single responsibility, such as uploading pending attachments. Keep it bounded: do a limited amount of work per run, then reschedule if needed.

// Kotlin (Android) - simplified example using WorkManager CoroutineWorker
class UploadPendingAttachmentsWorker(
  appContext: Context,
  params: WorkerParameters
) : CoroutineWorker(appContext, params) {

  override suspend fun doWork(): Result {
    // 1) Load a small batch of pending attachments from local storage
    val batch = repository.loadPendingAttachments(limit = 10)
    if (batch.isEmpty()) return Result.success()

    // 2) Upload each item; checkpoint after each
    for (item in batch) {
      val ok = uploader.upload(item)
      if (!ok) {
        // Let WorkManager retry according to policy
        return Result.retry()
      }
      repository.markUploaded(item.id)
    }

    // 3) If more remain, ask WorkManager to run again soon
    return if (repository.hasMorePendingAttachments()) Result.retry() else Result.success()
  }
}

Notes: returning Result.retry() is not a tight loop; WorkManager applies backoff and OS scheduling. If you need immediate continuation while the app is in foreground, do that in-app instead of relying on background scheduling.

Step 2: Choose constraints

Uploads usually require network and may prefer charging for large files. Configure constraints accordingly.

val constraints = Constraints.Builder()
  .setRequiredNetworkType(NetworkType.CONNECTED)
  .setRequiresBatteryNotLow(true)
  .build()

Step 3: Enqueue as unique work

Use unique work names to avoid duplicate jobs. This is critical when multiple events trigger scheduling (user edits, connectivity change, push).

val request = OneTimeWorkRequestBuilder<UploadPendingAttachmentsWorker>()
  .setConstraints(constraints)
  .setBackoffCriteria(
    BackoffPolicy.EXPONENTIAL,
    30, TimeUnit.SECONDS
  )
  .addTag("uploads")
  .build()

WorkManager.getInstance(context)
  .enqueueUniqueWork(
    "upload_pending_attachments",
    ExistingWorkPolicy.KEEP,
    request
  )

KEEP ensures you do not enqueue multiple identical workers. If you want the newest request to replace older ones (e.g., updated constraints), use REPLACE carefully.

Step 4: Observe and surface user-visible progress (when appropriate)

Background work is often invisible, but some tasks deserve transparency: large uploads, exports, or anything that affects user expectations. WorkManager supports progress updates, but remember that the OS may pause your work. For user-critical long tasks, consider a foreground service via WorkManager’s setForeground() so the user sees a notification and the OS grants more execution priority.

// Inside worker
setProgress(workDataOf("uploaded" to uploadedCount, "total" to totalCount))

Step 5: Periodic maintenance (use sparingly)

For periodic tasks like “refresh reference data once a day,” use PeriodicWorkRequest. Android enforces a minimum interval (commonly 15 minutes). Treat it as best-effort.

val periodic = PeriodicWorkRequestBuilder<RefreshReferenceDataWorker>(
  24, TimeUnit.HOURS
)
  .setConstraints(
    Constraints.Builder()
      .setRequiredNetworkType(NetworkType.CONNECTED)
      .build()
  )
  .build()

WorkManager.getInstance(context)
  .enqueueUniquePeriodicWork(
    "refresh_reference_data",
    ExistingPeriodicWorkPolicy.KEEP,
    periodic
  )

Practical Step-by-Step: iOS Scheduling with BGTaskScheduler

On iOS, BGTaskScheduler is the primary API for scheduling background refresh and processing tasks. The system decides when to run them based on usage patterns and power conditions.

Step 1: Register task identifiers

Registration must happen early in app launch. Use stable identifiers and add them to your app’s permitted identifiers in Info.plist.

// Swift (iOS)
import BackgroundTasks

func registerBackgroundTasks() {
  BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.example.app.refresh",
    using: nil
  ) { task in
    self.handleAppRefresh(task: task as! BGAppRefreshTask)
  }

  BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.example.app.processing",
    using: nil
  ) { task in
    self.handleProcessing(task: task as! BGProcessingTask)
  }
}

Step 2: Schedule tasks at appropriate times

Schedule after meaningful events: after the user creates content, after a successful login, when the app enters background, or after receiving a push that indicates new server data. iOS does not guarantee exact timing; earliestBeginDate is only a hint.

// Schedule a refresh task
func scheduleRefresh() {
  let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
  request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // hint: 30 minutes

  do {
    try BGTaskScheduler.shared.submit(request)
  } catch {
    // If submission fails, rely on next app launch or other triggers
  }
}

Use BGProcessingTaskRequest for heavier work that can wait for charging or needs more time.

func scheduleProcessing() {
  let request = BGProcessingTaskRequest(identifier: "com.example.app.processing")
  request.requiresNetworkConnectivity = true
  request.requiresExternalPower = true

  do {
    try BGTaskScheduler.shared.submit(request)
  } catch {
  }
}

Step 3: Implement handlers with expiration support

iOS provides an expiration handler; you must stop promptly and save progress. Treat expiration as normal.

func handleAppRefresh(task: BGAppRefreshTask) {
  // Always schedule the next one early in the handler
  scheduleRefresh()

  let queue = OperationQueue()
  queue.maxConcurrentOperationCount = 1

  task.expirationHandler = {
    queue.cancelAllOperations()
    // Persist partial progress if needed
  }

  let op = RefreshOperation() // your Operation subclass
  op.completionBlock = {
    task.setTaskCompleted(success: !op.isCancelled)
  }

  queue.addOperation(op)
}

Keep the refresh handler lightweight. For large transfers, prefer Background URLSession so the system can manage the network work even if your app is suspended.

Step 4: Use Background URLSession for uploads/downloads

If your offline-first app uploads large media, Background URLSession is often more reliable than trying to keep code running. The system performs transfers and later wakes your app to process completion callbacks.

// Create a background session
let config = URLSessionConfiguration.background(withIdentifier: "com.example.app.bgtransfer")
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

// Create an upload task
let task = session.uploadTask(with: request, fromFile: fileURL)
task.resume()

Design implication: your “job” becomes “enqueue transfers and reconcile results,” not “perform the entire upload loop in-process.”

Scheduling Strategy Patterns That Work Across Platforms

Pattern: Coalescing triggers into a single scheduler entry point

Many events can indicate “background work needed.” If each event schedules its own job, you risk duplication and wasted resources. Instead, create a single function like scheduleMaintenanceIfNeeded() that:

  • Checks if there is any eligible work (pending uploads, pending refresh, cleanup needed).

  • Schedules one unique job/task per category (uploads, refresh, cleanup).

  • Uses OS-appropriate APIs (WorkManager unique work; BGTaskScheduler identifiers).

This keeps your scheduling logic consistent and testable.

Pattern: Split heavy work into “prepare” and “execute”

When OS time is short, do preparation in the background task and offload heavy network transfer to system-managed mechanisms where possible.

  • Prepare: select items, build requests, enqueue transfers, persist intent.

  • Execute: system performs transfer (iOS background session) or WorkManager runs under constraints (Android).

  • Finalize: mark completion, update local state, notify UI on next launch.

Pattern: User-initiated “Sync now” as a foreground fast path

Because background scheduling is best-effort, provide a foreground path for users who need immediate results. “Sync now” should run with fewer OS constraints because the app is active. The background scheduler remains a safety net, not the only mechanism.

Pattern: Budget-aware work (time and bytes)

OSes implicitly budget background work. You can make your jobs more likely to succeed by self-imposing budgets:

  • Stop after N items or after T seconds and reschedule.

  • Prefer Wi‑Fi for large payloads; use cellular only for small critical updates.

  • Defer compaction/index rebuild until charging.

This reduces the chance that the OS kills your job mid-flight and improves perceived reliability.

Common Job Types in Offline-First Apps (and How to Schedule Them)

Upload pending media

Constraints: network required; optionally unmetered + charging for large files. Mechanism: Android WorkManager with network constraints; iOS Background URLSession plus a BGProcessingTask to enqueue transfers when needed.

Refresh reference data

Constraints: network required; can be periodic (daily) and also triggered by push. Mechanism: periodic WorkManager; BGAppRefreshTask on iOS. Keep payload small and incremental.

Cleanup and storage maintenance

Constraints: no network; prefer charging/idle; avoid when storage is low. Mechanism: WorkManager with requiresCharging; BGProcessingTask with requiresExternalPower. Ensure cleanup never deletes user data that is not safely persisted elsewhere.

Reconciliation after connectivity returns

Constraints: network required; should be event-driven. Mechanism: on Android, listen to connectivity changes and enqueue unique work; on iOS, schedule a refresh task and also attempt when the app becomes active. If you have push, use it to prompt a refresh attempt, but do not assume delivery.

Testing and Verification Under Realistic OS Behavior

Test for delays and cancellations

Do not only test “happy path” where the job runs immediately. Test scenarios where:

  • The job starts, runs for a few seconds, then is terminated.

  • The job is delayed for hours.

  • Multiple triggers occur rapidly (ensure unique work prevents duplicates).

  • Constraints change mid-run (network drops, battery saver enabled).

Android tooling

Use WorkManager’s testing utilities (in instrumentation tests) to run workers synchronously and validate that they persist state correctly. Also test on real devices with Battery Saver and Doze-like conditions; emulators do not always reflect OEM power management.

iOS tooling

Use Xcode’s background task debugging features to simulate launches and expiration. Verify that your handlers schedule the next task and that expiration cancels work cleanly. For background URLSession, test app termination and device lock to ensure transfers still complete and callbacks are handled.

Operational Observability: Knowing Whether Background Work Actually Runs

Because scheduling is best-effort, you need visibility into what happened. Add lightweight instrumentation:

  • Persist last-run timestamps per job type and last outcome (success, cancelled, failed).

  • Record counts: items attempted, items completed, bytes transferred.

  • Expose a diagnostics screen (hidden behind a gesture or debug menu) that shows pending work and last background execution.

  • Log OS signals: on Android, WorkInfo states; on iOS, task completion success flags and background session events.

This helps you distinguish “scheduler never ran us” from “we ran but had nothing to do” or “we ran but were expired.”

Putting It Together: A Cross-Platform Scheduling Checklist

  • Model background work as discrete, bounded jobs with clear responsibilities.

  • Use OS-native schedulers (WorkManager, BGTaskScheduler) and system transfer APIs (Background URLSession) where appropriate.

  • Coalesce triggers and use unique work identifiers to prevent duplication.

  • Choose constraints that match user expectations and battery impact.

  • Implement expiration/cancellation handling and persist progress frequently.

  • Provide a foreground fast path for time-sensitive user actions.

  • Instrument and test under delays, throttling, and termination.

Now answer the exercise about the content:

In an offline-first mobile app, why is event-driven background scheduling generally preferred over running a periodic timer every few minutes?

You are right! Congratulations, now go to the next page

You missed! Try again.

Background execution is best-effort: OSes may throttle, batch, delay, or cancel work. Event-driven one-off jobs reduce wasted wakeups and let the OS run work when conditions are favorable, while your app remains correct even if execution is late.

Next chapter

Authentication, Token Refresh, and Offline Authorization Decisions

Arrow Right Icon
Free Ebook cover Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms
63%

Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms

New course

19 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.