Home / Blog / BGTaskScheduler in the wild: what actually works in production

BGTaskScheduler in the wild: what actually works in production

Background work on iOS is rationed but possible. What I've actually shipped with BGTaskScheduler, BGAppRefreshTask, and BGProcessingTask in production apps.

When an iOS app moves to the background, execution time is tightly rationed. Apple enforces an aggressive policy to protect the user’s battery. But some work has to happen in the background anyway: data sync, cache pre-warm, critical notifications.

BGTaskScheduler, introduced in iOS 13, is a big step up from the old BackgroundAppRefresh API. I’ve used it in four apps now, and the patterns have settled.

What BGTaskScheduler is

iOS hands your app background time “when it feels like it”. “When it feels like it” means:

  • The user is asleep (overnight)
  • Wifi plus charging
  • The iPhone is idle
  • System priorities have calmed down

You can’t say “run in 5 minutes”. You say “run no earlier than 30 minutes from now, at a convenient time”. The system decides.

Two task types

BGAppRefreshTask: short (30 seconds), frequent. Feed refresh, notification count updates, that kind of thing.

BGProcessingTask: long (up to a few minutes), rare. ML model downloads, database maintenance, large data sync.

Each has its own scheduling constraints.

Basic setup

1. Declare permissions in Info.plist:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.example.app.refresh</string>
    <string>com.example.app.cleanup</string>
</array>

2. Register in AppDelegate:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
    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.cleanup",
        using: nil
    ) { task in
        self.handleCleanup(task: task as! BGProcessingTask)
    }
    return true
}

3. Schedule:

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)  // 15 minutes out
    
    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Failed to schedule: (error)")
    }
}

Call scheduleAppRefresh() at a sensible moment, usually as the app transitions from foreground to background.

Handler: do the work, then reschedule

func handleAppRefresh(task: BGAppRefreshTask) {
    // Reschedule immediately
    scheduleAppRefresh()
    
    task.expirationHandler = {
        // Clean up if the task runs out of time
        self.cancelCurrentOperation()
    }
    
    Task {
        let success = await refreshData()
        task.setTaskCompleted(success: success)
    }
}

Key points:
– You must call setTaskCompleted(success:), otherwise the system throttles your app
– Reschedule right away; forget that and you get called once and never again
expirationHandler is your graceful-cleanup path when time runs out

Real use case 1: fitness app, data sync

In a fitness tracking app, health data (steps, calories) is pulled from HealthKit and pushed to our own backend. Even when the user doesn’t open the app, the last 24 hours needs to be on the server for weekly and monthly aggregates.

Pattern:
– BGAppRefreshTask fires roughly every four hours (whenever the system sees fit)
– Pull the delta from HealthKit for the last four hours
– Save to the local DB (offline-first)
– Push to backend (if wifi is available)
– Mark the task complete

Thirty seconds is plenty because it’s a delta sync.

Result: even when users don’t open the app, the weekly summary email has the right numbers, and streaks don’t break.

Real use case 2: e-commerce, cart pre-load

In an e-commerce app, to make the next open feel fast:

  • BGAppRefreshTask fetches the latest cart state from the backend
  • Prefetch featured products and cache them locally
  • Pre-download product images
  • When the user opens the app, it loads instantly

The pattern makes the app feel “always up to date”. No spinner on open.

One caveat: if the user’s on a metered connection, downloading large images over cellular is rude. Use URLSessionConfiguration.waitsForConnectivity = true or restrict to wifi.

Real use case 3: finance app, database vacuum

In an expense tracking app, the DB file grows over time and queries slow down. BGProcessingTask handles:

  • Weekly DB vacuum
  • Archiving old records (records older than 30 days become summary-only)
  • Index rebuild
  • Only runs while charging and on wifi

The BGProcessingTaskRequest constraints:

let request = BGProcessingTaskRequest(identifier: "com.example.app.db-maintenance")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = true  // only when charging
request.earliestBeginDate = Date(timeIntervalSinceNow: 7 * 24 * 60 * 60)  // weekly

The user never notices, but DB performance stays healthy.

Real use case 4: messaging app, offline message pull

A push notification comes in, but the app is closed. When the user opens the app, the latest messages should be there already.

  • BGAppRefreshTask fetches the last 50 messages from the backend
  • Saves them to the local DB
  • Updates unread counts
  • Updates the badge number

This background refresh is a safety net on top of push notifications. If a push is lost (rare, but it happens), background refresh recovers.

Testing is the hard part

BGTaskScheduler is painful to debug. To trigger one manually in the simulator, use lldb:

(lldb) e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.app.refresh"]

To know whether your tasks actually run in production, log aggressively:

  • Log when a task is registered
  • Log when a task is scheduled
  • Log when a task starts (timestamp)
  • Log when a task ends (duration, success or failure)

Ship those logs to Firebase Analytics or your own backend and build a dashboard for “task run frequency per user”.

When it runs, when it doesn’t

iOS won’t commit to when it’ll run your task. Common conditions:

  • The user is inactive (screen off, phone on the table)
  • Wifi is available (preferred)
  • Battery is adequate
  • Device has idle capacity
  • The user opens your app often (if they don’t, the system runs your task less)

If your app is a utility someone opens once a week, background refresh rarely fires. Apple sees it as unused and gives it little time.

For daily active users, typical frequency is three to eight runs per day.

Respect user resources

Background work burns battery. Don’t do unnecessary work.

  • Sync the delta only, never the whole dataset
  • Minimise network requests in the background
  • Heavy computation (ML inference) belongs in BGProcessingTask with a power constraint
  • If the user disables background app refresh, degrade the feature quietly, don’t nag

App Store submission considerations

Apple review questions your background usage. Every background task identifier needs a clear purpose. Tracking or ad work alone gets rejected.

Legitimate purposes:
– Content freshness
– Offline-first data sync
– Maintenance and cleanup
– Proactive UX (instant load)

Closing thought

BGTaskScheduler is the lever iOS gives you in its stingy background model. Use it thoughtfully. Unnecessary background work gets throttled, drains batteries, and attracts one-star reviews.

Pattern:
– Small deltas
– Idempotent operations
– Graceful expiration
– Respect the constraints
– Measure with logging

Done right, the app feels “always fresh” while keeping battery impact negligible.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch