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) // weeklyThe 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.