← Back to Blog

Running iOS Background Tasks Reliably, Part 1

Lessons learned while chasing highly reliable iOS background task running in iOS 26

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:

  1. 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.
  2. BGProcessingTask - for more intensive work: "a processing task that can take minutes to complete".
  3. 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 BGContinuedProcessingTask starts 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.

Four trigger paths that lead into ActionProcessor.requestExecution and the RunGate actor. The actor allows one active run and merges mid-run requests into a single follow-up.
In CalCopilot, there are four events can kick off action processing: a foreground launch from a user opening the app (which runs actions immediately and then on a timer), a wakeup by 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:

  1. Run all of the enabled actions in order
  2. Send notifications
  3. Re-arm all of the background scheduled tasks
  4. 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:

Decision tree for every re-arm: no pending request means submit, a nil earliestBeginDate means skip, a pending request firing no later than desired plus tolerance means skip, otherwise submit because the new request has an earlier earliestBeginDate
Three of the four paths end in "leave the pending request alone." The fourth path submits only when the new request would have an earlier 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.

Two rows of discrete background wakes across one day: processing wakes roughly hourly overnight on the charger, refresh wakes during waking hours clustered around phone use, with no within-day decay
A typical day of execution runs on my own device, where I unplugged in the morning at 7am and plugged back in at 10pm. Notice how the two task types almost never cross over?

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():

  • uptime under 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.

Sketch of the pattern observed on my device: refresh launches decline after several days without a foreground launch and eventually stop, while processing launches continue for longer
This sketch shows the pattern I observed on my device. Refresh launches declined after several days without a foreground launch and eventually stopped. Processing launches continued for longer.
### A reboot stops background launches until the first unlock

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:

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!