On this page 10 sections
- What background work is for, and what it is not
- Refresh tasks versus processing tasks
- Registering, scheduling and handling a task
- Expiring work and the time you actually get
- What iOS uses to decide whether you run
- Testing with a launch argument and a debugger
- Common reasons a task never fires
- Background delivery versus push and widgets
- Key takeaways
- 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.
- An identifier in
Info.plistunderBGTaskSchedulerPermittedIdentifiers, and the matchingUIBackgroundModesentry (fetch,processing, or both). - A registration with
BGTaskScheduler.shared.register(forTaskWithIdentifier:using:launchHandler:)that runs before the app finishes launching. Theusing:queue can benilfor the main queue; if the work is heavy, use a background queue, but keep UI off it. - 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:
- Refresh: one modest network round trip, a parse, a save, and out. No “and also upload analytics, and also recompute thumbnails”.
- Processing: work that can checkpoint. After each chunk, check
Task.isCancelledand be ready to finish. - Always complete the task.
success: falseis an acceptable outcome. An uncompleted task is not.
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:
- Background App Refresh is enabled for the system and for your app. If the user has turned it off, your refresh tasks will not run. That is their choice; do not try to defeat it.
- How the user treats the app. An app they open regularly, and that has previously completed background work quickly, is in a better position than an app they installed and ignored, or an app that expires every time.
- Battery, Low Power Mode and charging. Processing tasks that require external power will wait. Refresh work is less welcome on a low battery.
- Thermal state and system load.
earliestBeginDate. This delays the earliest attempt. It does not mean “run at this date”. A date two minutes from now may still lead to a run hours later, or not during that day.
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:
- Test on a device. The simulator has improved in this area, but a device is still the honest place to confirm Info.plist, modes, and the debugger hook.
- Log a single line when you register, when you submit, when the handler starts, when you complete, and when you expire. Those five lines, with timestamps, will tell you more than a week of guessing.
- Force expiration once, on purpose, and confirm that work stops and state is consistent.
- Do not judge success by overnight hope alone. If you need to know that the handler is correct, simulate. If you need to know that the system is willing to run you in the wild, look at those logs over several real days, on a phone you use normally, with Background App Refresh on.
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:
- The identifier in code does not exactly match
BGTaskSchedulerPermittedIdentifiers. UIBackgroundModesis missingfetchorprocessingfor the type you submitted.- You registered after launch, or only in a SwiftUI view’s
.task. - You never submitted a request, or you submitted only on a path that is not taken when the app backgrounds.
- You are waiting for
earliestBeginDateas if it were an alarm. - Background App Refresh is off, or Low Power Mode is on, or the device has hardly been used with your app in the foreground.
- You submitted a processing task that
requiresExternalPowerand the device has not been plugged in while idle. - The previous run never called
setTaskCompleted, or it ran so long that the system has no interest in inviting you back soon. - You tested only in a fresh install on a desk, for twenty minutes, and called that a verdict.
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:
- The user should look now because something happened on a server: a visible push, with permission they have granted. See the push notifications guide in this series.
- A widget or lock screen surface should show a new number: a widget timeline, and perhaps a refresh task to feed it, not a refresh task alone that nobody can see.
- A Live Activity is already on screen: App Intents and activity updates, not a processing task.
- A large file must finish uploading after the user leaves: a background
URLSession. - You would like to pre-warm content for the next foreground: a
BGAppRefreshTask, scheduled opportunistically. - You have a heavy maintenance job: a
BGProcessingTaskwith honest constraints, checkpointing, and no promise of when.
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
- Use
BGAppRefreshTaskfor short, opportunistic updates andBGProcessingTaskfor longer, deferrable maintenance. Neither is a cron job. - Register before launch finishes, list every identifier in
Info.plist, submit a request when you background, and always callsetTaskCompleted. - Honour
expirationHandler: cancel, checkpoint, complete. Budget refresh work to a short burst. earliestBeginDateis a delay, not a timetable. User settings, battery and how the app is used all affect whether you run.- Test the handler with Apple’s debugger simulation on a device, and use logs, not hope, to see whether the system is inviting you in the field.
- Prefer a push, a widget timeline or a background
URLSessionwhen those are the actual job.
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.