axiom-background-processing by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-background-processing后台执行是一种特权,而非权利。iOS 积极限制后台工作以保护电池寿命和用户体验。核心原则:将后台任务视为酌情处理的工作——你请求一个时间窗口,系统决定何时(或是否)运行你的代码。
关键见解:大多数“我的任务从未运行”问题源于注册错误或误解了控制执行的 7 个调度因素。本技能提供系统性调试,而非猜测。
能耗优化:关于减少后台任务对电池的影响,请参阅 axiom-energy 技能。本技能专注于任务机制——使任务正确运行并可靠完成。
要求:iOS 13+ (BGTaskScheduler), iOS 26+ (BGContinuedProcessingTask), Xcode 15+
开发者提出的、本技能解答的真实问题:
→ 本技能涵盖了针对“任务从未运行”问题的注册检查清单和调试决策树
→ 本技能涵盖了 LLDB 调试命令和模拟器限制
→ 本技能涵盖了任务类型(BGAppRefresh 30秒 vs BGProcessing 数分钟)、过期处理程序和增量进度保存
→ 本技能提供了根据工作持续时间和系统要求选择正确任务类型的决策树
→ 本技能涵盖了将 BGTask 过期桥接到结构化并发的 withTaskCancellationHandler 模式
→ 本技能涵盖了 7 个调度因素、节流行为以及生产环境调试
如果你看到以下任何情况,请怀疑是注册或调度问题:
submit() 成功,但处理程序从未被调用广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
axiom-energy 技能)在调试代码之前,务必验证以下内容:
<!-- 在 Info.plist 中必需 -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.refresh</string>
<string>com.yourapp.processing</string>
</array>
<!-- 对于 BGAppRefreshTask -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<!-- 对于 BGProcessingTask(添加到 UIBackgroundModes) -->
<array>
<string>fetch</string>
<string>processing</string>
</array>
常见错误:代码中的标识符与 Info.plist 不完全匹配。检查拼写错误、大小写敏感性。
注册必须在应用完成启动前发生:
// ✅ 正确:在 didFinishLaunchingWithOptions 中注册
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
// 安全强制转换:标识符保证 BGAppRefreshTask 类型
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true // 在返回前注册
}
// ❌ 错误:在启动后或按需注册
func someButtonTapped() {
// 太晚了 - 注册将无效
BGTaskScheduler.shared.register(...)
}
例外:BGContinuedProcessingTask (iOS 26+) 在用户发起操作时使用动态注册。
在 Console.app 中过滤后台任务事件:
subsystem:com.apple.backgroundtaskscheduler
查找:
关键:如果用户从应用切换器中强制退出应用,不会运行任何后台任务。
在应用切换器中检查:你的应用是否仍然可见?划掉关闭 = 在用户再次启动前没有后台执行。
Need to run code in the background?
│
├─ User initiated the action explicitly (button tap)?
│ ├─ iOS 26+? → BGContinuedProcessingTask (Pattern 4)
│ └─ iOS 13-25? → beginBackgroundTask + save progress (Pattern 5)
│
├─ Keep content fresh throughout the day?
│ ├─ Runtime needed ≤ 30 seconds? → BGAppRefreshTask (Pattern 1)
│ └─ Need several minutes? → BGProcessingTask with constraints (Pattern 2)
│
├─ Deferrable maintenance work (DB cleanup, ML training)?
│ └─ BGProcessingTask with requiresExternalPower (Pattern 2)
│
├─ Large downloads/uploads?
│ └─ Background URLSession (Pattern 6)
│
├─ Triggered by server data changes?
│ └─ Silent push notification → fetch data → complete handler (Pattern 7)
│
└─ Short critical work when app backgrounds?
└─ beginBackgroundTask (Pattern 5)
| 类型 | 运行时间 | 何时运行 | 使用场景 |
|---|---|---|---|
| BGAppRefreshTask | ~30 秒 | 基于用户应用使用模式 | 获取最新内容 |
| BGProcessingTask | 数分钟 | 设备充电、空闲时(通常在夜间) | 维护、ML 训练 |
| BGContinuedProcessingTask | 延长 | 系统管理并显示进度 UI | 用户发起的导出/发布 |
| beginBackgroundTask | ~30 秒 | 进入后台时立即 | 保存状态、完成上传 |
| Background URLSession | 根据需要 | 系统友好的时间,即使在终止后 | 大文件传输 |
使用时机:你需要获取新内容,以便用户在打开应用时感觉内容新鲜。
运行时间:~30 秒
系统何时运行它:基于用户的应用使用模式预测。如果用户每天早上打开应用,系统会学习并在之前刷新。
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true
}
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
// earliestBeginDate = 最小延迟,非精确时间
// 系统可能根据使用模式在几小时后运行
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 至少 15 分钟
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule refresh: \(error)")
}
}
// 当应用进入后台时调用
func applicationDidEnterBackground(_ application: UIApplication) {
scheduleAppRefresh()
}
// 或者使用 SceneDelegate / SwiftUI
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}
func handleAppRefresh(task: BGAppRefreshTask) {
// 1. 立即设置过期处理程序
task.expirationHandler = { [weak self] in
// 取消任何进行中的工作
self?.currentOperation?.cancel()
}
// 2. 调度下一次刷新(连续刷新模式)
scheduleAppRefresh()
// 3. 执行工作
fetchLatestContent { [weak self] result in
switch result {
case .success:
task.setTaskCompleted(success: true)
case .failure:
task.setTaskCompleted(success: false)
}
}
}
关键点:
setTaskCompleted(成功和失败)使用时机:可以等待最佳系统条件(充电、WiFi、空闲)的维护工作。
运行时间:数分钟
系统何时运行它:通常在设备充电的夜间。可能不会每天运行。
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}
func scheduleMaintenanceIfNeeded() {
// 要有责任心 —— 仅在确实需要工作时才调度
guard needsMaintenance() else { return }
let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance")
// 关键:为 CPU 密集型工作设置 requiresExternalPower
request.requiresExternalPower = true
// 可选:需要网络进行云同步
request.requiresNetworkConnectivity = true
// 不要将 earliestBeginDate 设置得太远 —— 最多约 1 周
// 如果用户不返回应用,任务将不会运行
do {
try BGTaskScheduler.shared.submit(request)
} catch BGTaskScheduler.Error.unavailable {
print("Background processing not available")
} catch {
print("Failed to schedule: \(error)")
}
}
func handleMaintenance(task: BGProcessingTask) {
var shouldContinue = true
task.expirationHandler = { [weak self] in
shouldContinue = false
self?.saveProgress() // 保存部分进度!
}
Task {
do {
// 分块处理,检查是否过期
for chunk in workChunks {
guard shouldContinue else {
// 过期被调用 —— 优雅地停止
break
}
try await processChunk(chunk)
saveProgress() // 每块后设置检查点
}
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
关键点:
requiresExternalPower = true(防止电池消耗)earliestBeginDate 设置得超过一周使用时机:使用现代 async/await 模式的 SwiftUI 应用。
@main
struct MyApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}
// 处理应用刷新
.backgroundTask(.appRefresh("com.yourapp.refresh")) {
// 调度下一次刷新
scheduleAppRefresh()
// 异步工作 —— 当闭包返回时任务完成
await fetchLatestContent()
}
// 处理后台 URLSession 事件
.backgroundTask(.urlSession("com.yourapp.downloads")) {
// 当后台 URLSession 完成时调用
await processDownloadedFiles()
}
}
}
SwiftUI 优势:
setTaskCompleted)使用时机:用户明确发起工作(按钮点击),该工作应在后台继续,并显示可见进度。
不适用于:自动任务、维护、同步
// 1. Info.plist —— 对动态后缀使用通配符
// BGTaskSchedulerPermittedIdentifiers:
// "com.yourapp.export.*"
// 2. 当用户发起操作时注册(非启动时)
func userTappedExportButton() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.export.photos"
) { task in
let continuedTask = task as! BGContinuedProcessingTask
self.handleExport(task: continuedTask)
}
// 立即提交
let request = BGContinuedProcessingTaskRequest(
identifier: "com.yourapp.export.photos",
title: "Exporting Photos",
subtitle: "0 of 100 photos"
)
// 可选:如果无法立即启动则失败
request.strategy = .fail // 或 .enqueue(默认)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
showError("Cannot export in background right now")
}
}
// 3. 带有强制进度报告的处理程序
func handleExport(task: BGContinuedProcessingTask) {
var shouldContinue = true
task.expirationHandler = {
shouldContinue = false
}
// 强制:报告进度(无更新的任务会自动过期)
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = 0
Task {
for (index, photo) in photos.enumerated() {
guard shouldContinue else { break }
await exportPhoto(photo)
// 更新进度 —— 系统向用户显示此信息
task.progress.completedUnitCount = Int64(index + 1)
}
task.setTaskCompleted(success: shouldContinue)
}
}
关键点:
.fail 策略使用时机:应用正在进入后台,你需要约 30 秒来完成关键工作(保存状态、完成上传)。
var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
// 启动后台任务
backgroundTaskID = application.beginBackgroundTask(withName: "Save State") { [weak self] in
// 过期处理程序 —— 清理并结束任务
self?.saveProgress()
if let taskID = self?.backgroundTaskID {
application.endBackgroundTask(taskID)
}
self?.backgroundTaskID = .invalid
}
// 执行关键工作
saveEssentialState { [weak self] in
// 一旦完成就结束任务 —— 不要等待过期
if let taskID = self?.backgroundTaskID, taskID != .invalid {
UIApplication.shared.endBackgroundTask(taskID)
self?.backgroundTaskID = .invalid
}
}
}
关键点:
endBackgroundTask(不仅仅在过期处理程序中)使用时机:即使应用终止也应继续的大文件下载/上传。
// 1. 创建后台配置
lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.yourapp.downloads"
)
config.sessionSendsLaunchEvents = true // 完成时重新启动应用
config.isDiscretionary = true // 系统选择最佳时间
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
// 2. 开始下载
func downloadFile(from url: URL) {
let task = backgroundSession.downloadTask(with: url)
task.resume()
}
// 3. 处理会话事件的应用重新启动(AppDelegate)
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// 存储完成处理程序 —— 在处理事件后调用
backgroundSessionCompletionHandler = completionHandler
// 会话委托方法将被调用
}
// 4. URLSessionDelegate
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// 所有事件已处理 —— 调用存储的完成处理程序
DispatchQueue.main.async {
self.backgroundSessionCompletionHandler?()
self.backgroundSessionCompletionHandler = nil
}
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// 在返回前将文件从临时位置移走
let destinationURL = getDestinationURL(for: downloadTask)
try? FileManager.default.moveItem(at: location, to: destinationURL)
}
关键点:
nsurlsessiond)—— 应用终止后继续isDiscretionary = true(系统等待 WiFi、充电)handleEventsForBackgroundURLSession 以重新启动应用使用时机:服务器需要唤醒应用以获取新数据。
{
"aps": {
"content-available": 1
},
"custom-data": "fetch-new-messages"
}
为能效使用 apns-priority: 5(而非 10)。
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
Task {
do {
let hasNewData = try await fetchLatestData()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}
关键点:
当使用结构化并发时,将 BGTask 过期桥接到任务取消:
func handleAppRefresh(task: BGAppRefreshTask) {
// 创建一个尊重过期的 Task
let workTask = Task {
try await withTaskCancellationHandler {
// 你的异步工作
try await fetchAndProcessData()
task.setTaskCompleted(success: true)
} onCancel: {
// 当 task.cancel() 被调用时同步调用
// 注意:在任意线程上运行,保持轻量
}
}
// 将过期桥接到取消
task.expirationHandler = {
workTask.cancel() // 触发 onCancel 块
}
}
// 在你的工作中检查取消
func fetchAndProcessData() async throws {
for item in items {
// 检查是否应该停止
try Task.checkCancellation()
// 或非抛出检查
guard !Task.isCancelled else {
saveProgress()
return
}
try await process(item)
}
}
关键点:
withTaskCancellationHandler 处理任务挂起时的取消Task.checkCancellation() 如果取消则抛出 CancellationErrorTask.isCancelled 用于非抛出检查后台任务在模拟器中不会自动运行。你必须手动触发它们。
当应用在附加调试器的情况下运行时,暂停执行并运行:
// 触发任务启动
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
// 触发任务过期(测试过期处理程序)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]
_simulateLaunchForTaskWithIdentifier 命令_simulateExpirationForTaskWithIdentifier 测试过期setTaskCompleted?来自 WWDC 2020-10063 "Background execution demystified":
| 因素 | 描述 | 影响 |
|---|---|---|
| 电池电量极低 | <20% 电量 | 所有酌情处理的工作暂停 |
| 低电量模式 | 用户启用 | 后台活动受限 |
| 应用使用情况 | 用户启动应用的频率 | 使用越多 = 优先级越高 |
| 应用切换器 | 应用是否仍然可见? | 划掉关闭 = 无后台 |
| 后台应用刷新 | 系统设置 | 关闭 = 无 BGAppRefresh 任务 |
| 系统预算 | 能耗/数据预算 | 随启动消耗,随时间重新填充 |
| 速率限制 | 系统间隔 | 防止过于频繁的启动 |
// 检查低电量模式
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// 减少后台工作
}
// 监听变化
NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
.sink { _ in
// 调整行为
}
// 检查后台应用刷新状态
let status = UIApplication.shared.backgroundRefreshStatus
switch status {
case .available:
break // 可以调度
case .denied:
// 用户已禁用 —— 提示在设置中启用
case .restricted:
// 家长控制或 MDM —— 无法启用
}
fetch, processing)?didFinishLaunchingWithOptions 中并在返回之前?earliestBeginDate 是否不太远(最多约 1 周)?submit() 错误?getPendingTaskRequests)?setTaskCompleted(success:)?诱惑:"轮询比推送通知更简单。我们需要实时更新。"
现实:
时间成本比较:
实际有效的方法:
反驳模板:"iOS 后台执行不支持轮询间隔。BGAppRefreshTask 基于 iOS 预测用户何时会打开我们的应用而运行,而非固定时间表。对于实时更新,我们需要服务器端推送通知。让我向你展示苹果关于此的文档。"
诱惑:"我只需使用 beginBackgroundTask 并完成所有工作。"
现实:
实际有效的方法:
requiresExternalPower = true(夜间运行)反驳模板:"iOS 限制后台运行时间以保护电池。对于需要数分钟的工作,我们有两个选择:(1) BGProcessingTask 在充电时夜间运行 —— 非常适合维护,(2) 将工作分成能在 30 秒内完成的块,在运行之间保存进度。哪个更适合我们的使用场景?"
诱惑:"代码是正确的 —— 一定是用户设备问题。"
现实:附加 Xcode 的调试版本与生产环境中的发布版本行为不同。
常见原因:
调试步骤:
backgroundRefreshStatus,记录它反驳模板:"后台执行取决于 7 个系统因素,包括电池电量、用户应用使用模式以及他们是否强制退出了应用。让我添加日志以了解受影响用户的情况。"
诱惑:"后台工作是一个锦上添花的功能。"
现实:
时间成本比较:
最小可行的后台:
// 在 didFinishLaunchingWithOptions 中
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
task.setTaskCompleted(success: true) // 占位符
self.scheduleRefresh()
}
反驳模板:"后台刷新是 [应用类型] 的核心期望。最小实现是 20 行代码。如果我们现在发布时不包含它,稍后再添加,我们可能会遇到注册时机错误。让我现在添加脚手架,以便我们可以在发布后增强它。"
症状:submit() 成功但处理程序从未被调用。
诊断:
// 代码使用:
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.myapp.Refresh", // 大写 R
...
)
// Info.plist 有:
// "com.myapp.refresh" // 小写 r
修复:标识符必须完全匹配(区分大小写)。
浪费的时间:2 小时调试代码逻辑,而问题是拼写错误。
症状:处理程序运行,工作似乎完成,但下一次调度的任务从未运行。
诊断:
func handleRefresh(task: BGAppRefreshTask) {
fetchData { result in
switch result {
case .success:
task.setTaskCompleted(success: true) // ✅ 已调用
case .failure:
// ❌ 缺少 setTaskCompleted!
print("Failed")
}
}
}
修复:在所有代码路径中调用 setTaskCompleted,包括错误。
case .failure:
task.setTaskCompleted(success: false) // ✅ 现在已调用
影响:未能调用 setTaskCompleted 可能导致系统惩罚应用的后台预算。
症状:用户报告后台同步无效。开发者无法复现。
诊断:
用户:"我每晚关闭应用以节省电量。"
开发者:"你如何关闭它们?"
用户:"在应用切换器中向上划掉。"
现实:从应用切换器中划掉关闭 = 强制退出 = 在用户再次打开应用前没有后台任务。
修复:
症状:BGProcessingTask 已调度但从未执行。
诊断:用户晚上给手机充电,但任务有 requiresExternalPower = true 且用户使用无线充电器。
等等,这不是问题。真正的问题:
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
// 缺少:request.requiresExternalPower = true
没有 requiresExternalPower,系统仍然等待充电但确定性较低。显式设置它给系统一个清晰的信号。
另外:用户必须在约 2 周内在前台启动过应用,处理任务才有资格运行。
// 触发任务
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"IDENTIFIER"]
// 触发过期
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"IDENTIFIER"]
subsystem:com.apple.backgroundtaskscheduler
| 需求 | 使用 | 运行时间 |
|---|---|---|
| 保持内容新鲜 | BGAppRefreshTask | ~30秒 |
| 繁重的维护 | BGProcessingTask + requiresExternalPower | 数分钟 |
| 用户发起的延续 | BGContinuedProcessingTask (iOS 26) | 延长 |
| 在后台完成 | beginBackgroundTask | ~30秒 |
| 大文件下载 | Background URLSession | 根据需要 |
| 服务器触发 | 静默推送通知 | ~30秒 |
WWDC:2019-707, 2020-10063, 2022-10142, 2023-10170, 2025-227
文档:/backgroundtasks/bgtaskscheduler, /backgroundtasks/starting-and-terminating-tasks-during-development
技能:axiom-background-processing-ref, axiom-background-processing-diag, axiom-energy
最后更新:2025-12-31 平台:iOS 13+, iOS 26+ (BGContinuedProcessingTask) 状态:生产就绪的后台任务模式
每周安装次数
93
仓库
GitHub 星标数
601
首次出现
2026年1月21日
安全审计
安装于
opencode78
claude-code73
codex72
gemini-cli71
cursor70
github-copilot68
Background execution is a privilege , not a right. iOS actively limits background work to protect battery life and user experience. Core principle : Treat background tasks as discretionary jobs — you request a time window, the system decides when (or if) to run your code.
Key insight : Most "my task never runs" issues stem from registration mistakes or misunderstanding the 7 scheduling factors that govern execution. This skill provides systematic debugging, not guesswork.
Energy optimization : For reducing battery impact of background tasks, see axiom-energy skill. This skill focuses on task mechanics — making tasks run correctly and complete reliably.
Requirements : iOS 13+ (BGTaskScheduler), iOS 26+ (BGContinuedProcessingTask), Xcode 15+
Real questions developers ask that this skill answers:
→ The skill covers the registration checklist and debugging decision tree for "task never runs" issues
→ The skill covers LLDB debugging commands and simulator limitations
→ The skill covers task types (BGAppRefresh 30s vs BGProcessing minutes), expiration handlers, and incremental progress saving
→ The skill provides decision tree for choosing the correct task type based on work duration and system requirements
→ The skill covers withTaskCancellationHandler patterns for bridging BGTask expiration to structured concurrency
→ The skill covers the 7 scheduling factors, throttling behavior, and production debugging
If you see ANY of these, suspect registration or scheduling issues:
submit()axiom-energy skill)ALWAYS verify these before debugging code :
<!-- Required in Info.plist -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.refresh</string>
<string>com.yourapp.processing</string>
</array>
<!-- For BGAppRefreshTask -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<!-- For BGProcessingTask (add to UIBackgroundModes) -->
<array>
<string>fetch</string>
<string>processing</string>
</array>
Common mistake : Identifier in code doesn't EXACTLY match Info.plist. Check for typos, case sensitivity.
Registration MUST happen before app finishes launching:
// ✅ CORRECT: Register in didFinishLaunchingWithOptions
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
// Safe force cast: identifier guarantees BGAppRefreshTask type
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true // Register BEFORE returning
}
// ❌ WRONG: Registering after launch or on-demand
func someButtonTapped() {
// TOO LATE - registration won't work
BGTaskScheduler.shared.register(...)
}
Exception : BGContinuedProcessingTask (iOS 26+) uses dynamic registration when user initiates the action.
Filter Console.app for background task events:
subsystem:com.apple.backgroundtaskscheduler
Look for:
Critical : If user force-quits app from App Switcher, NO background tasks will run.
Check in App Switcher: Is your app still visible? Swiping away = no background execution until user launches again.
Need to run code in the background?
│
├─ User initiated the action explicitly (button tap)?
│ ├─ iOS 26+? → BGContinuedProcessingTask (Pattern 4)
│ └─ iOS 13-25? → beginBackgroundTask + save progress (Pattern 5)
│
├─ Keep content fresh throughout the day?
│ ├─ Runtime needed ≤ 30 seconds? → BGAppRefreshTask (Pattern 1)
│ └─ Need several minutes? → BGProcessingTask with constraints (Pattern 2)
│
├─ Deferrable maintenance work (DB cleanup, ML training)?
│ └─ BGProcessingTask with requiresExternalPower (Pattern 2)
│
├─ Large downloads/uploads?
│ └─ Background URLSession (Pattern 6)
│
├─ Triggered by server data changes?
│ └─ Silent push notification → fetch data → complete handler (Pattern 7)
│
└─ Short critical work when app backgrounds?
└─ beginBackgroundTask (Pattern 5)
| Type | Runtime | When Runs | Use Case |
|---|---|---|---|
| BGAppRefreshTask | ~30 seconds | Based on user app usage patterns | Fetch latest content |
| BGProcessingTask | Several minutes | Device charging, idle (typically overnight) | Maintenance, ML training |
| BGContinuedProcessingTask | Extended | System-managed with progress UI | User-initiated export/publish |
| beginBackgroundTask | ~30 seconds | Immediately when backgrounding | Save state, finish upload |
| Background URLSession | As needed | System-friendly time, even after termination | Large transfers |
Use when : You need to fetch new content so app feels fresh when user opens it.
Runtime : ~30 seconds
When system runs it : Predicted based on user's app usage patterns. If user opens app every morning, system learns and refreshes before then.
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
return true
}
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
// earliestBeginDate = MINIMUM delay, not exact time
// System may run hours later based on usage patterns
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // At least 15 min
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule refresh: \(error)")
}
}
// Call when app enters background
func applicationDidEnterBackground(_ application: UIApplication) {
scheduleAppRefresh()
}
// Or with SceneDelegate / SwiftUI
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}
func handleAppRefresh(task: BGAppRefreshTask) {
// 1. IMMEDIATELY set expiration handler
task.expirationHandler = { [weak self] in
// Cancel any in-progress work
self?.currentOperation?.cancel()
}
// 2. Schedule NEXT refresh (continuous refresh pattern)
scheduleAppRefresh()
// 3. Do the work
fetchLatestContent { [weak self] result in
switch result {
case .success:
task.setTaskCompleted(success: true)
case .failure:
task.setTaskCompleted(success: false)
}
}
}
Key points :
setTaskCompleted in ALL code paths (success AND failure)Use when : Maintenance work that can wait for optimal system conditions (charging, WiFi, idle).
Runtime : Several minutes
When system runs it : Typically overnight when device is charging. May not run daily.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.maintenance",
using: nil
) { task in
self.handleMaintenance(task: task as! BGProcessingTask)
}
func scheduleMaintenanceIfNeeded() {
// Be conscientious — only schedule when work is actually needed
guard needsMaintenance() else { return }
let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance")
// CRITICAL: Set requiresExternalPower for CPU-intensive work
request.requiresExternalPower = true
// Optional: Require network for cloud sync
request.requiresNetworkConnectivity = true
// Don't set earliestBeginDate too far — max ~1 week
// If user doesn't return to app, task won't run
do {
try BGTaskScheduler.shared.submit(request)
} catch BGTaskScheduler.Error.unavailable {
print("Background processing not available")
} catch {
print("Failed to schedule: \(error)")
}
}
func handleMaintenance(task: BGProcessingTask) {
var shouldContinue = true
task.expirationHandler = { [weak self] in
shouldContinue = false
self?.saveProgress() // Save partial progress!
}
Task {
do {
// Process in chunks, checking for expiration
for chunk in workChunks {
guard shouldContinue else {
// Expiration called — stop gracefully
break
}
try await processChunk(chunk)
saveProgress() // Checkpoint after each chunk
}
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
Key points :
requiresExternalPower = true for CPU-intensive work (prevents battery drain)earliestBeginDate more than a week aheadUse when : SwiftUI app using modern async/await patterns.
@main
struct MyApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .background {
scheduleAppRefresh()
}
}
// Handle app refresh
.backgroundTask(.appRefresh("com.yourapp.refresh")) {
// Schedule next refresh
scheduleAppRefresh()
// Async work — task completes when closure returns
await fetchLatestContent()
}
// Handle background URLSession events
.backgroundTask(.urlSession("com.yourapp.downloads")) {
// Called when background URLSession completes
await processDownloadedFiles()
}
}
}
SwiftUI advantages :
setTaskCompleted needed)Use when : User explicitly initiates work (button tap) that should continue after backgrounding, with visible progress.
NOT for : Automatic tasks, maintenance, syncing
// 1. Info.plist — use wildcard for dynamic suffix
// BGTaskSchedulerPermittedIdentifiers:
// "com.yourapp.export.*"
// 2. Register WHEN user initiates action (not at launch)
func userTappedExportButton() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.export.photos"
) { task in
let continuedTask = task as! BGContinuedProcessingTask
self.handleExport(task: continuedTask)
}
// Submit immediately
let request = BGContinuedProcessingTaskRequest(
identifier: "com.yourapp.export.photos",
title: "Exporting Photos",
subtitle: "0 of 100 photos"
)
// Optional: Fail if can't start immediately
request.strategy = .fail // or .enqueue (default)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
showError("Cannot export in background right now")
}
}
// 3. Handler with mandatory progress reporting
func handleExport(task: BGContinuedProcessingTask) {
var shouldContinue = true
task.expirationHandler = {
shouldContinue = false
}
// MANDATORY: Report progress (tasks with no progress auto-expire)
task.progress.totalUnitCount = 100
task.progress.completedUnitCount = 0
Task {
for (index, photo) in photos.enumerated() {
guard shouldContinue else { break }
await exportPhoto(photo)
// Update progress — system shows this to user
task.progress.completedUnitCount = Int64(index + 1)
}
task.setTaskCompleted(success: shouldContinue)
}
}
Key points :
.fail strategy when work is only useful if it starts immediatelyUse when : App is backgrounding and you need ~30 seconds to finish critical work (save state, complete upload).
var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
func applicationDidEnterBackground(_ application: UIApplication) {
// Start background task
backgroundTaskID = application.beginBackgroundTask(withName: "Save State") { [weak self] in
// Expiration handler — clean up and end task
self?.saveProgress()
if let taskID = self?.backgroundTaskID {
application.endBackgroundTask(taskID)
}
self?.backgroundTaskID = .invalid
}
// Do critical work
saveEssentialState { [weak self] in
// End task as soon as done — DON'T wait for expiration
if let taskID = self?.backgroundTaskID, taskID != .invalid {
UIApplication.shared.endBackgroundTask(taskID)
self?.backgroundTaskID = .invalid
}
}
}
Key points :
endBackgroundTask AS SOON as work completes (not just in expiration handler)Use when : Large downloads/uploads that should continue even if app terminates.
// 1. Create background configuration
lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.yourapp.downloads"
)
config.sessionSendsLaunchEvents = true // App relaunched when complete
config.isDiscretionary = true // System chooses optimal time
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
// 2. Start download
func downloadFile(from url: URL) {
let task = backgroundSession.downloadTask(with: url)
task.resume()
}
// 3. Handle app relaunch for session events (AppDelegate)
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// Store completion handler — call after processing events
backgroundSessionCompletionHandler = completionHandler
// Session delegate methods will be called
}
// 4. URLSessionDelegate
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// All events processed — call stored completion handler
DispatchQueue.main.async {
self.backgroundSessionCompletionHandler?()
self.backgroundSessionCompletionHandler = nil
}
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// Move file from temp location before returning
let destinationURL = getDestinationURL(for: downloadTask)
try? FileManager.default.moveItem(at: location, to: destinationURL)
}
Key points :
nsurlsessiond) — continues after app terminationisDiscretionary = true for non-urgent (system waits for WiFi, charging)handleEventsForBackgroundURLSession for app relaunchUse when : Server needs to wake app to fetch new data.
{
"aps": {
"content-available": 1
},
"custom-data": "fetch-new-messages"
}
Use apns-priority: 5 (not 10) for energy efficiency.
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
Task {
do {
let hasNewData = try await fetchLatestData()
completionHandler(hasNewData ? .newData : .noData)
} catch {
completionHandler(.failed)
}
}
}
Key points :
When using structured concurrency, bridge BGTask expiration to task cancellation:
func handleAppRefresh(task: BGAppRefreshTask) {
// Create a Task that respects expiration
let workTask = Task {
try await withTaskCancellationHandler {
// Your async work
try await fetchAndProcessData()
task.setTaskCompleted(success: true)
} onCancel: {
// Called synchronously when task.cancel() is invoked
// Note: Runs on arbitrary thread, keep lightweight
}
}
// Bridge expiration to cancellation
task.expirationHandler = {
workTask.cancel() // Triggers onCancel block
}
}
// Checking cancellation in your work
func fetchAndProcessData() async throws {
for item in items {
// Check if we should stop
try Task.checkCancellation()
// Or non-throwing check
guard !Task.isCancelled else {
saveProgress()
return
}
try await process(item)
}
}
Key points :
withTaskCancellationHandler handles cancellation while task is suspendedTask.checkCancellation() throws CancellationError if cancelledTask.isCancelled for non-throwing checkBackground tasks do not run automatically in simulator. You must manually trigger them.
While app is running with debugger attached, pause execution and run:
// Trigger task launch
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
// Trigger task expiration (test expiration handler)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]
_simulateLaunchForTaskWithIdentifier command_simulateExpirationForTaskWithIdentifiersetTaskCompleted called in all code paths?From WWDC 2020-10063 "Background execution demystified":
| Factor | Description | Impact |
|---|---|---|
| Critically Low Battery | <20% battery | All discretionary work paused |
| Low Power Mode | User-enabled | Background activity limited |
| App Usage | How often user launches app | More usage = higher priority |
| App Switcher | App still visible? | Swiped away = no background |
| Background App Refresh | System setting | Off = no BGAppRefresh tasks |
| System Budgets | Energy/data budgets | Deplete with launches, refill over day |
| Rate Limiting | System spacing | Prevents too-frequent launches |
// Check Low Power Mode
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// Reduce background work
}
// Listen for changes
NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
.sink { _ in
// Adapt behavior
}
// Check Background App Refresh status
let status = UIApplication.shared.backgroundRefreshStatus
switch status {
case .available:
break // Good to schedule
case .denied:
// User disabled — prompt to enable in Settings
case .restricted:
// Parental controls or MDM — can't enable
}
fetch, processing)?didFinishLaunchingWithOptions BEFORE return?earliestBeginDate not too far in future (max ~1 week)?submit() errors?getPendingTaskRequests)?setTaskCompleted(success:) called in ALL code paths?The temptation : "Polling is simpler than push notifications. We need real-time updates."
The reality :
Time cost comparison :
What actually works :
Pushback template : "iOS background execution doesn't support polling intervals. BGAppRefreshTask runs based on when iOS predicts the user will open our app, not on a fixed schedule. For real-time updates, we need server-side push notifications. Let me show you Apple's documentation on this."
The temptation : "I'll just use beginBackgroundTask and do all my work."
The reality :
What actually works :
requiresExternalPower = true (runs overnight)Pushback template : "iOS limits background runtime to protect battery. For work that needs several minutes, we have two options: (1) BGProcessingTask runs overnight when charging — great for maintenance, (2) Break work into chunks that complete in 30 seconds, saving progress between runs. Which fits our use case better?"
The temptation : "The code is correct — it must be a user device issue."
The reality : Debug builds with Xcode attached behave differently than release builds in the wild.
Common causes :
Debugging steps :
backgroundRefreshStatus at launch, log itPushback template : "Background execution depends on 7 system factors including battery level, user app usage patterns, and whether they force-quit the app. Let me add logging to understand what's happening for affected users."
The temptation : "Background work is a nice-to-have feature."
The reality :
Time cost comparison :
Minimum viable background :
// In didFinishLaunchingWithOptions
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil
) { task in
task.setTaskCompleted(success: true) // Placeholder
self.scheduleRefresh()
}
Pushback template : "Background refresh is a core expectation for [type of app]. The minimum implementation is 20 lines of code. If we ship without it and add later, we risk registration timing bugs. Let me add the scaffolding now so we can enhance it post-launch."
Symptom : submit() succeeds but handler never called.
Diagnosis :
// Code uses:
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.myapp.Refresh", // Capital R
...
)
// Info.plist has:
// "com.myapp.refresh" // lowercase r
Fix : Identifiers must EXACTLY match (case-sensitive).
Time wasted : 2 hours debugging code logic when issue was typo.
Symptom : Handler runs, work appears to complete, but next scheduled task never runs.
Diagnosis :
func handleRefresh(task: BGAppRefreshTask) {
fetchData { result in
switch result {
case .success:
task.setTaskCompleted(success: true) // ✅ Called
case .failure:
// ❌ Missing setTaskCompleted!
print("Failed")
}
}
}
Fix : Call setTaskCompleted in ALL code paths including errors.
case .failure:
task.setTaskCompleted(success: false) // ✅ Now called
Impact : Failing to call setTaskCompleted may cause system to penalize app's background budget.
Symptom : Users report background sync doesn't work. Developer can't reproduce.
Diagnosis :
User: "I close my apps every night to save battery."
Developer: "How do you close them?"
User: "Swipe up in the app switcher."
Reality : Swiping away from App Switcher = force quit = no background tasks until user opens app again.
Fix :
Symptom : BGProcessingTask scheduled but never executes.
Diagnosis : User has phone plugged in at night, but task has requiresExternalPower = true and user uses wireless charger.
Wait, that's not the issue. Real issue:
let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
// Missing: request.requiresExternalPower = true
Without requiresExternalPower, system STILL waits for charging but has less certainty. Setting it explicitly gives system clear signal.
Also: User must have launched app in foreground within ~2 weeks for processing tasks to be eligible.
// Trigger task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"IDENTIFIER"]
// Trigger expiration
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"IDENTIFIER"]
subsystem:com.apple.backgroundtaskscheduler
| Need | Use | Runtime |
|---|---|---|
| Keep content fresh | BGAppRefreshTask | ~30s |
| Heavy maintenance | BGProcessingTask + requiresExternalPower | Minutes |
| User-initiated continuation | BGContinuedProcessingTask (iOS 26) | Extended |
| Finish on background | beginBackgroundTask | ~30s |
| Large downloads | Background URLSession | As needed |
| Server-triggered | Silent push notification | ~30s |
WWDC : 2019-707, 2020-10063, 2022-10142, 2023-10170, 2025-227
Docs : /backgroundtasks/bgtaskscheduler, /backgroundtasks/starting-and-terminating-tasks-during-development
Skills : axiom-background-processing-ref, axiom-background-processing-diag, axiom-energy
Last Updated : 2025-12-31 Platforms : iOS 13+, iOS 26+ (BGContinuedProcessingTask) Status : Production-ready background task patterns
Weekly Installs
93
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode78
claude-code73
codex72
gemini-cli71
cursor70
github-copilot68