Home

Making background tasks actually run

I registered a background refresh task, scheduled it, and it never ran. Not “ran late” — never, over several days, on a device I was using normally.

Everything was correct except six things.

The registration must happen before launch finishes

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions: …) -> Bool {
    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: "com.example.refresh",
        using: nil
    ) { task in
        self.handleRefresh(task as! BGAppRefreshTask)
    }
    return true
}

Registering after didFinishLaunching returns throws. Registering in a lazy initialiser, or inside a .task modifier, or anywhere that runs after the first frame, is too late.

This one is at least loud — it crashes rather than silently failing.

The identifier must be in Info.plist

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

Miss this and registration fails silently. Every identifier you register needs an entry, exactly matching.

You also need the Background Modes capability, with Background fetch or Background processing checked depending on the task type.

You must reschedule every time

This is the one that got me. A task runs once. If you want it to run again, the handler must schedule the next one:

func handleRefresh(_ task: BGAppRefreshTask) {
    scheduleNextRefresh()          // FIRST — before doing any work

    let operation = RefreshOperation()

    task.expirationHandler = {
        operation.cancel()
    }

    operation.completionBlock = {
        task.setTaskCompleted(success: !operation.isCancelled)
    }

    queue.addOperation(operation)
}

func scheduleNextRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
    try? BGTaskScheduler.shared.submit(request)
}

Schedule the next one first, before the work. If the work throws or the task is killed on expiry, you still have a future task scheduled — otherwise one failure ends background refresh permanently until the next app launch.

You must call setTaskCompleted

If you do not, iOS kills the task at expiry and records it as misbehaving. A few of those and the system stops scheduling you.

Every path — success, failure, cancellation — must call it exactly once.

Warning

expirationHandler gives you a few seconds’ warning before the task is killed. Use it to cancel the work and call setTaskCompleted(success: false). A task that is killed without completing is the strongest negative signal you can send the scheduler.

earliestBeginDate is a floor, not a schedule

The name is honest and people read it as a promise. It means “not before this”, and iOS decides the actual time based on battery level, charging state, network availability, thermal state, and — most importantly — how often the user opens your app.

An app the user opens daily gets background time. An app they opened once last week does not, and no amount of correct code changes that. This is by design.

Setting earliestBeginDate to one minute does not make it run in a minute. Fifteen minutes is a sensible floor for a refresh task; anything shorter is ignored anyway.

Testing it without waiting days

This is the part that makes the whole thing tractable. Pause in the debugger and run:

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

That triggers the task immediately. There is an equivalent for forcing expiry:

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

Both are private API and both are debugger-only — do not put them in shipping code. They turn a multi-day feedback loop into ten seconds, and testing the expiry path is otherwise nearly impossible.

The two task types

BGAppRefreshTaskRequest — short, around 30 seconds, for updating content. Runs more often.

BGProcessingTaskRequest — minutes, for heavy work like database maintenance or bulk uploads. Can require external power and network:

let request = BGProcessingTaskRequest(identifier: "com.example.cleanup")
request.requiresExternalPower = true
request.requiresNetworkConnectivity = true

Setting requiresExternalPower makes it much more likely to run — typically overnight while charging — at the cost of not running for a user who never plugs in overnight.

What finally worked

For me the fix was three things: rescheduling at the top of the handler rather than the bottom, calling setTaskCompleted on the expiry path (which I had not implemented at all), and accepting that the task runs roughly once or twice a day rather than every fifteen minutes.

That last part was the real adjustment. Background execution is a budget the system allocates based on how much the user values your app, and the correct design is one that works well when it gets some background time and degrades gracefully when it gets none.