You would think, having a supercomputer in your pocket, that you could make it reliably perform various routine processing tasks in the background while you carry on with your life. Kind of like what cron on Unix systems has done since the 70s.
In this first blog post of this 3-part series, I'll describe the early parts of the journey with BGTaskScheduler which is Apple's framework for running background tasks on iOS devices. And how through trial, error and observation I got to a reasonable level of reliability in my own app called Calendar Copilot that I use for syncing events between calendars.
To save you the suspense: Although it's not as reliable as cron, you can get good enough scheduling out of BGTaskScheduler but with some gotchas - namely that you (or your users) have to reopen an app every week or two. If you don't, iOS eventually reduces and then stops background tasks to save battery.
A quick primer on iOS Background Processing types
There's a few common types of background processing tasks:
- BGAppRefreshTask - intended for keeping your app's "content" fresh. Apple's own description is a "short refresh task", and that's as specific as the docs get.
- BGProcessingTask - for more intensive work: "a processing task that can take minutes to complete".
- BGContinuedProcessingTask - new in iOS 26, and described as "a task that starts in the foreground and can continue running in the background as needed". The system shows the task's progress in a Live Activity and lets the user cancel the task. Because a
BGContinuedProcessingTaskstarts in the foreground, it's no use at all for automated sync.
Apple publishes no hard numbers for any of this that I found, and it took some trial and error (and telemetry hooked up to my personal device) to understand the limits and constraints.
BGAppRefreshTask or BGProcessingTask, or a registered observer on EKEventStoreChanged that wakes on external calendar changes. Every request calls ActionProcessor.requestExecution, and its RunGate actor allows one active run process-wide. I call this the execution lock. Requests that arrive mid-run don't stack up. They coalesce into a single pending follow-up, and at most one follow-up runs when the active run finishes.Scheduling either task type follows the same pattern. Submit a request and register a launch handler. In the launch handler, set an expiration handler that iOS invokes shortly before the task's time expires:
// Refresh: schedule every 30 min, anchored to the last run.
let refresh = BGAppRefreshTaskRequest(identifier: "com.popovetsky.CalCopilot.refresh")
refresh.earliestBeginDate = lastRun.addingTimeInterval(30 * 60) // last run + 30 min
try BGTaskScheduler.shared.submit(refresh)
// Processing: schedule every hour, anchored to the last run.
let processing = BGProcessingTaskRequest(identifier: "com.popovetsky.CalCopilot.process")
processing.earliestBeginDate = lastRun.addingTimeInterval(60 * 60) // last run + 60 min
try BGTaskScheduler.shared.submit(processing)
// Processing example
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.popovetsky.CalCopilot.process",
using: nil
) { task in
handle(task as! BGProcessingTask)
}
func handle(_ task: BGProcessingTask) {
// Set an expiration handler so you can cancel unfinished work.
// Apple: "Not setting an expiration handler results in the system marking
// your task as complete and unsuccessful". Skip it and you lose the run
// with no chance to clean up.
task.expirationHandler = {
cancelInFlightWork() // stop cleanly, don't corrupt a half-done write
task.setTaskCompleted(success: false) // careful: this can run at the same time as the normal finish below
}
Task {
let ok = await runSync() // <-- the actual work happens here
task.setTaskCompleted(success: ok) // normal finish (the other side of that race)
}
}
Processing has two extra knobs: requiresExternalPower and requiresNetworkConnectivity. Set requiresExternalPower to true to restrict the task to times when the device is connected to power. I left it set to false because most processing runs on my device already happened while charging. I'd rather take the occasional off-charger wake than forbid it outright.
processing.requiresExternalPower = false // allow off-charger runs
Maximizing success with Intentionally Aggressive background task scheduling
So you quickly realize that BGTaskScheduler is nothing like cron. Just because you request a background task to run at a certain time doesn't even remotely guarantee that it will happen. Sometimes it may be hours later that it actually runs. On my phone, I discovered a pattern: I observed that iOS tended to launch processing tasks while the phone was charging overnight, and refresh tasks during the day while the phone was on battery and I frequently unlocked the screen.
After a ton of performance optimization, I had a full CalCopilot execution run typically completing in under a second and almost always in less than 2. That was short enough to utilize either task type, so I registered both BGRefresh and BGProcessing tasks to give iOS the most opportunities to execute the actions. At first, every execution run did the same 4 things sequentially:
- Run all of the enabled actions in order
- Send notifications
- Re-arm all of the background scheduled tasks
- Perform health checks and storage maintenance (I later moved this to processing only, since it gets more time and doesn't need to occur as frequently)
A mistake I made early on was to perform the health checks and storage maintenance in the refresh task, and discovered that refresh wakes were much less reliable. I can't prove from one device that expensive refresh runs caused fewer wakes. Apple does, however, document two factors that would explain it: rate limiting and the app's energy budget. WWDC 2020's "Background execution demystified" explains that the budget depletes as the app runs and refills over the day. Apple recommends limiting energy and cellular data use per launch, so I now keep refresh runs shorter than processing runs.
Dealing with sudden task expiration
Not only is BGTaskScheduler mercurial about starting your tasks, but it also doesn't guarantee a task the time it may need to finish. So, I implemented proactive expiration timers for both task types: I gave processing runs a 25-second budget and refresh runs a 3-second budget. That allowed me to cleanly cancel the remaining work and reliably report the task as unsuccessful.
Even with those limits, iOS occasionally stops a run early. If you look again at the handler code above and you'll see two paths calling setTaskCompleted(success:) on the same task object. One path is normal completion after runSync() returns. The other is expirationHandler, which iOS calls when the task's time is nearly up. You must call setTaskCompleted(success:) exactly once for each task.
The expiration handler and the normal completion path can run at the same time. Both paths call setTaskCompleted(success:) for the same task. Since a plain Bool cannot safely coordinate these calls (because both paths can read and write it concurrently), I stored the completion state in an OSAllocatedUnfairLock, which is Apple's low-level lock for protecting shared state. The first call changes the state from incomplete to complete and reports completion to iOS, and a later call reads the completed state and returns without reporting completion again.
On my device, the telemetry showed that iOS sometimes called expirationHandler after the task had already completed normally. The BGTaskCompletionToken class below records that the task is already complete. Without that token, the telemetry reports each late callback as a task expiration.
public final class BGTaskCompletionToken: @unchecked Sendable {
private let completed = OSAllocatedUnfairLock<Bool>(initialState: false)
@discardableResult
public func complete(
_ task: BGTaskCompletable?,
success: Bool,
beforeComplete: (() -> Void)? = nil
) -> Bool {
let shouldComplete = completed.withLock { done -> Bool in
guard !done else { return false }
done = true
return true
}
guard shouldComplete else { return false }
beforeComplete?()
task?.setTaskCompleted(success: success)
return true
}
}
To combat race conditions, I added a completion token:
let token = BGTaskCompletionToken()
task.expirationHandler = { token.complete(task, success: false) }
// ... run the sync ...
token.complete(task, success: ok)
Background task scheduling gotchas
BGTaskScheduler gives you only one pending request per task identifier, and Apple is explicit about what happens if you submit a second one: "Submitting a task request for an unexecuted task that's already in the queue replaces the previous task request." So you need to double check that you're not forever kicking the scheduled task down the road without it ever actually running.
Since I went with an aggressive re-arming strategy, resubmitting both task types constantly: after every run, on every calendar change, and on every background transition. My first version computed desiredEarliest = now + interval and submitted unconditionally on every re-arm. So a refresh request that was going to fire in 5 minutes got thrown away and replaced with one 30 minutes out. Five minutes later, another re-arm pushed it out again. iOS was forever about to wake the app.
Don't make this mistake! Instead: first, compare the pending request with the request you would submit now:
static func shouldSkipSubmit(
existingEarliest: Date?,
desiredEarliest: Date,
tolerance: TimeInterval = scheduleReplacementTolerance
) -> Bool {
guard let existingEarliest else {
// A nil earliestBeginDate means the existing request can run as
// soon as the scheduler permits it, so replacing it would only
// move the request later.
return true
}
// Keep the existing request if it can start at or before the new time.
// This includes the "already overdue" case (existing earliest in the
// past): iOS can run it at any time, and resubmitting would push the
// earliestBeginDate further out. Keep the request that iOS is already
// waiting on.
return existingEarliest <= desiredEarliest.addingTimeInterval(tolerance)
}
I did end up adding a 5-second tolerance, so the code ignores insignificant differences between the existing and newly calculated dates. Every re-arm follows one of these four paths:
earliestBeginDate. The guard makes most re-arms a no-op, so constant re-arming is safe.Second, compute both requests from a fixed reference time. Instead of now + interval, anchor to the last run:
// The interval is 1 hour on iOS 26. It doubles after a failed submit,
// and it never exceeds 4 hours.
let minimumDelay: TimeInterval = processingSchedulingRetryCount > 0 ? 300 : 60
let desiredEarliest = max(
now.addingTimeInterval(minimumDelay), // a near-term floor
lastRun.addingTimeInterval(interval) // anchored to the last run, not to now
)
Even a legitimate submit computed from now would move the target later with every re-arm. Using lastRun + interval keeps the target time unchanged across repeated submissions. Both task types use that anchor, refresh with a 30-minute interval and processing with a 1-hour interval.
BTW, that 60-second minimumDelay in that code does not mean iOS will launch the app in a minute. Apple's docs on earliestBeginDate are clear that "the system doesn't guarantee launching the task at the specified date, but only that it won't begin sooner." earliestBeginDate is a lower bound, not a scheduled launch time.
Gotchas with task scheduling degradation
To understand BGTaskScheduler's behavior in the wild, I added OpenTelemetry-based instrumentation and turned it on for my own phone. (A note to my users: Calendar Copilot does not send diagnostic telemetry unless you enable the Diagnostic Telemetry setting, and it's off by default. Only enable it if I'm personally working with you to debug a problem, otherwise leave it off.)
Plotted by time of day, my background wakes didn't scatter at random. Processing ran overnight while the phone was connected to the charger. Refresh ran only during waking hours and clustered around the times I was actually using the phone.
A calendar sync should happen within some reasonable amount of time, right? When I saw a refresh request remain pending for many hours past its earliestBeginDate, I assumed it was stuck. I added a detector that forced a new near-term submit whenever a request was overdue by more than twice its requested interval.
But that was wrong, because iOS will eventually schedule your task when convenient and worrying about things being "stuck" is basically pointless. Compare two days, one with little use and one with heavy use:
| quiet day (barely touched it) | busy day (heavy foreground use) | |
|---|---|---|
| refresh dispatches | 2 | 9 |
| total background wakes | 11 | 42 |
| "stranded" fires | 5 | 13 |
The quiet day had two refresh dispatches and the busy day had nine. The app, the scheduling code, and the 30-minute requests were the same on both days. What changed was how much I used the phone, and app usage is one of the factors Apple names in that WWDC 2020 session. The "stranded" fires correlate with something else: total wake frequency. The more the phone woke for any reason, the more often my scheduling code ran. Each invocation evaluated the same naturally-overdue refresh request and flagged it again. Nothing was stranded.
The detector idea was a bust because iOS doesn't give you fixed-interval scheduling like cron - a lesson I learned the hard way over and over again. "Overdue by 2x the interval" is a meaningful alarm on cron. On iOS, my 30-minute refresh requests were sometimes being granted every 3 hours or so. So for most of the wait, a healthy request is more than 60 minutes overdue. My threshold was shorter than the platform's own cadence, so it fired on normal behavior. Resubmitting a near-term request couldn't make iOS launch a task it had decided not to grant.
Pending requests can outlast a reboot or an OS update
I also learned that scheduling requests sometimes stay in the queue even after a reboot or an OS upgrade. So I added a small check to init():
uptimeunder 600 seconds means the device booted within the last 10 minutes, so the reason is"post_reboot".- A stored OS version that no longer matches the current one means we just came up on a new build of iOS, so the reason is
"post_os_upgrade". - If neither condition is true, continue without a recovery reason and use the normal skip logic.
When you detect a reboot or OS upgrade, force a fresh near-term submission and bypass shouldSkipSubmit. Forced resubmission is appropriate here because a reboot or upgrade is an observable, one-off event. Elapsed time alone was never evidence that a request was stuck. My telemetry showed that a background task registration survived a reboot. iOS launched that task after the first unlock. The pending request also retained its original earliestBeginDate. Without the forced submit, the skip logic may preserve that date and delay the next wake by hours.
How user behavior and device state affect scheduling reliability
So you've seen how much control you have over scheduling as a developer, but user behavior can greatly impact this as well.
Force-quit kills background until the next manual launch
When the user swipes your app away in the App Switcher, iOS records that as an instruction to stop the app. Apple's Quinn ("eskimo") from Developer Technical Support describes the mechanism on the developer forums:
When the user 'force quits' an app by swiping up in the multitasking UI, iOS interprets that to mean that the user doesn't want the app running at all. So:
- If the app is running, iOS terminates it.
- iOS also sets a flag that prevents the app from being launched in the background. That flag gets cleared when the user next launches the app manually.
In that same thread, Quinn calls the swipe-away a clear statement of user intent and notes there's no documented way for your app to override it. There's nothing to engineer here. A force-quit means no background runtime until the user opens the app by hand, and your code never even runs, so it can't detect the flag.
The slow decay of an app you stopped opening
Even without a force-quit, iOS gradually gives an app fewer background launches when the user stops opening it. App usage is one of the factors Apple says the system weighs when allocating background runtime (WWDC 2020, "Background execution demystified").
From my own observations, when I'm opening CalCopilot regularly, refresh fires reliably, day in, day out. During a vacation trip where I never opened the app, refresh wakes had almost stopped by the end of the week. In a later stretch of more than two weeks without a single foreground open, refresh wakes stopped entirely, while processing started to peter off towards the end. This is my device, so don't hold me to the exact timelines, but from what I've seen 2-3 weeks is about the longest you can go between app launches if you want background task execution to keep running.
After a restart, iOS won't start your background task until the user first unlocks the device. From WWDC 2019, "Advances in App Background Execution":
We do guarantee that we won't start your task until the user first unlocks their device, so make sure any files you need to access are at most file protection type complete until first user authentication.
The relevant event is the first device unlock, not the next foreground launch. On my device, iOS launched the registered task after a reboot and the first unlock, even before a foreground launch of CalCopilot. But I wouldn't call this reliable, because Apple requires you to register the launch handlers before each app launch finishes, so register them during every background and foreground launch.
Explaining how to hold it correctly to paying users
Because the app runs only on the device, with no servers and no tricks, there are four things you need users to do:
- Reopen the app now and then. On my phone, refresh launches were more consistent when I opened the app every week or two.
- Notice when the notifications stop, and reopen. If expected notifications stop, manually launching the app can restore background scheduling. Calendar Copilot relies on those notifications to make a scheduling problem visible.
- Leave Background App Refresh on. If it is off in Settings, iOS stops all
BGAppRefreshTaskruns. Apple lists this setting as a scheduling factor. - Stay off Low Power Mode, and consider turning off Adaptive Power. Apple says Low Power Mode turns off Background App Refresh, and Adaptive Power can turn on Low Power Mode automatically when the battery reaches 20 percent.
Background execution remains dependent on user behavior and device state, regardless of how carefully the scheduler is implemented.
What's next?
More coming soon in part 2!