Start a project

Article · iOS · Background work · BGTaskScheduler · 12 min read

iOS background tasks with BGTaskScheduler

BGTaskScheduler is how an iOS app asks the system to run a little work later — a content refresh, a cleanup, a deferred sync — without pretending it has a daemon. You register a handler, submit a request, do a bounded amount of work when the system launches you, and tell the scheduler you finished. What you do not get is a guarantee: earliestBeginDate is the earliest start, not a timetable, and a task may simply not run.

On this page 10 sections
  1. What background work is for, and what it is not
  2. Refresh tasks versus processing tasks
  3. Registering, scheduling and handling a task
  4. Expiring work and the time you actually get
  5. What iOS uses to decide whether you run
  6. Testing with a launch argument and a debugger
  7. Common reasons a task never fires
  8. Background delivery versus push and widgets
  9. Key takeaways
  10. Frequently asked questions

This is about getting permitted background work to actually fire, and about debugging it when it does not. The battery cost of that work is a separate subject, covered in optimising battery efficiency. If you only need a widget or a Live Activity to look fresh, start with widgets and App Intents rather than a background task.

What background work is for, and what it is not

Background work is for jobs the user already understands as “keep this up to date when I am not looking”: fetch new items for a feed they open every day, expire a local cache, finish indexing a library they imported. It is not a way to run your app all the time, to track a location indefinitely, or to hit an API every 15 minutes as if you were a server.

If the work only matters when the app is on screen, do it on the foreground path and stop. If the work is a file upload that must finish after the user leaves, a background URLSession is often the better tool: the system owns the transfer, and your app is woken to handle delegate events. If the work is “tell the user something happened”, a push notification is the right channel, including a silent push when you truly need one and your server can send it.

BGTaskScheduler sits in the middle: occasional, deferrable, budgeted work, started at a time the system finds convenient. The user’s Background App Refresh setting, Low Power Mode, and how often they actually open the app all feed into that convenience. You are a guest in that decision.

Refresh tasks versus processing tasks

There are two request types, and mixing them up is the usual first mistake.

A BGAppRefreshTask is a short refresh of content, in the same family of work that older UIApplication fetch callbacks used to do. Use it to ask “is there anything new?” and to pull a modest payload. The window you get is brief. Budget for a small number of network calls and a quick write to disk, then finish. This is the type that belongs in UIBackgroundModes under fetch.

A BGProcessingTask is for longer maintenance: a database vacuum, a large re-download, a machine-learning update, a photo library export. You can tell the request that it requiresNetworkConnectivity and that it requiresExternalPower. The system will typically run these when the device is more likely to be idle and charging. The window is longer than a refresh, and it can still be cut short. This is the processing background mode.

A rule of thumb: if you can do it in a short burst and it benefits a user who will open the app later today, it is a refresh. If it might take minutes and can wait for a plug, it is processing. If it must happen at 09:00 sharp, it is not a background task; it is a local notification, a calendar event, or work you do when the app is next opened. A cron expression explainer is a useful way to notice that you are thinking in server schedules. iOS will not honour 0 9 * * * for BGTaskScheduler.

You can register both, with two identifiers, and submit one of each where that is honest. Do not submit a processing task every time a refresh would do; you will spend budget and still not get a wall-clock schedule.

Registering, scheduling and handling a task

Three pieces have to agree, or nothing runs and you get very little feedback.

  1. An identifier in Info.plist under BGTaskSchedulerPermittedIdentifiers, and the matching UIBackgroundModes entry (fetch, processing, or both).
  2. A registration with BGTaskScheduler.shared.register(forTaskWithIdentifier:using:launchHandler:) that runs before the app finishes launching. The using: queue can be nil for the main queue; if the work is heavy, use a background queue, but keep UI off it.
  3. A submitted request, usually at the end of a handler and when the app goes to the background, so there is always a next ask outstanding.
import BackgroundTasks
import UIKit

let refreshID = "uk.leej.example.refresh"

func registerBackgroundTasks() {
    BGTaskScheduler.shared.register(forTaskWithIdentifier: refreshID, using: nil) { task in
        guard let task = task as? BGAppRefreshTask else {
            task.setTaskCompleted(success: false)
            return
        }
        handleRefresh(task)
    }
}

func scheduleRefresh(earliest: Date? = Date(timeIntervalSinceNow: 60 * 60)) {
    let request = BGAppRefreshTaskRequest(identifier: refreshID)
    request.earliestBeginDate = earliest
    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        // Log. Common causes: identifier not in Info.plist, running on a simulator
        // without support, or submitting too many requests.
    }
}

func handleRefresh(_ task: BGAppRefreshTask) {
    scheduleRefresh() // ask again, before you start work

    let work = Task {
        do {
            try await RefreshService().refresh()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }

    task.expirationHandler = {
        work.cancel()
        task.setTaskCompleted(success: false)
    }
}

Call registerBackgroundTasks() from application(_:didFinishLaunchingWithOptions:) or the scene-equivalent early hook. If you register lazily, after the system has already tried to launch you for the task, you miss that launch.

submit replaces an outstanding request with the same identifier, so it is safe to call often. Catch the error; a typo in the identifier is a silent “it never runs” if you ignore it.

For a processing task the request type changes (BGProcessingTaskRequest), and you set requiresNetworkConnectivity or requiresExternalPower when that is true. Do not set requiresExternalPower on a refresh-sized job; you are only narrowing the window in which you are allowed to run.

Expiring work and the time you actually get

The system will call your expirationHandler. That is not an edge case; it is part of the design. When it fires, stop starting new work, cancel what you can, persist enough to resume, and call setTaskCompleted(success:). If you return from the handler without completing, or you ignore expiration and keep running, you can be killed, and you will have taught the system that you are a bad guest.

How long you actually get is not a number I will quote as a promise. Apple documents refresh tasks as short and processing tasks as able to run longer, especially when power and network constraints you declared are met. The figures people remember from older sample code have changed before, and they can change again. Budget like this instead:

If your refresh regularly expires, you have too much work, or you are retrying too aggressively inside the task. The URLSession retry policy should be tighter in the background than in the foreground: fewer attempts, shorter timeouts, and no long Retry-After sleeps.

Do not start a background URLSession transfer and then immediately setTaskCompleted unless you really have handed the work off. Completing the task tells the scheduler you are done; outstanding session events can still wake you later, but that is a different mechanism, and mixing the two without a clear owner leads to double completion or lost work.

What iOS uses to decide whether you run

Apple does not publish a formula, and I will not invent one. The documented and observable factors include:

There is no supported way to request “every morning at 7, in Europe/London, including on the day clocks change”. If you find yourself in the timezone and DST explorer or a DST cron simulator to plan a BGTaskScheduler request, you are using the wrong tool for the job. Use a local notification if the user needs to be nudged at a time; use a widget timeline if a glanceable surface needs a new entry; use a push if a server event should wake the device.

You can still set earliestBeginDate to something like “not sooner than an hour from now” so you do not ask for a refresh continuously. That is a courtesy, not a schedule.

Testing with a launch argument and a debugger

On a device, a submitted task may not run while you are sitting there with the debugger attached, because the conditions above are not met. Apple documents a way to simulate a launch from lldb:

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

There is a matching simulation for expiration. Both of these are debugging hooks, not public API in your shipping code. Do not call them from the app. Use them on a run where you have already registered the handler, scheduled a request, and backgrounded the app as the documentation for testing and launching your apps’ background tasks describes.

Practical testing habits:

Instruments can show you the energy and networking of the work when you trigger it; the Instruments guide is the right next step if a refresh is expensive. The scheduler will not prefer you because the work is important to you; it will prefer you if the work is cheap and well behaved.

Common reasons a task never fires

When a task “never runs”, walk this list before you rewrite the handler:

BGTaskScheduler also has a limit on how many requests you can have outstanding. Submit one per identifier, and let submit replace it, rather than inventing identifiers per screen.

If the handler runs but the work fails, that is an ordinary bug in your service, not a scheduler bug. Keep the two diagnoses separate in the log.

Background delivery versus push and widgets

Choose the channel that matches the event:

Silent pushes (content-available) can also wake you to fetch, with their own budget and their own failure modes, and they require a server. Do not pile a silent push, a refresh task and a processing task on the same job “to be safe”. Pick one owner.

Key takeaways

If you want help untangling a refresh that never fires, tell me about it.

Frequently asked questions

What is the difference between BGAppRefreshTask and BGProcessingTask?

A refresh task is a short content update. A processing task is longer maintenance, and can declare that it needs a network or that it should wait for external power. They use different UIBackgroundModes values and different request types. Most “just fetch the latest items” work should be a refresh.

Why did my iOS background task not run?

The usual causes are a mismatched identifier, a missing background mode, late registration, never submitting a request, Background App Refresh being off, or treating earliestBeginDate as an alarm. The system may also simply not have found a good moment, especially on a phone that rarely opens the app. Check logs for register, submit and handle before you assume the handler is wrong.

How do you test BGTaskScheduler in Xcode?

Register and submit as in production, background the app, then use the documented lldb command to simulate a launch for your task identifier, and the matching command to simulate expiration. Do this on a device. Do not ship those private simulation calls in the app.

Can iOS background tasks run on a schedule?

Not a precise one. You can delay the earliest start with earliestBeginDate, but the system chooses the real time, and it may skip you. If the user needs something at a clock time, use a local notification or do the work when they next open the app.

How long can an iOS background task run?

For a limited window that the system decides, shorter for refresh than for processing, and always subject to expiration. I will not quote a fixed number of seconds as if it were a contract; design the work so it can stop cleanly when expirationHandler runs, and so a refresh is a brief burst. If you regularly expire, cut the work down.

Written by Lee Johnson. Published 10 November 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project