Background tasks in iOS: real-world lessons and examples
Your app won't always be in the foreground, yet users expect it to sync data, process uploads, and refresh content while they're doing something else. Background tasks in iOS let you schedule work that runs when your app is backgrounded or even terminated, but they come with strict time budgets and system constraints that catch most developers off guard.
Why background tasks matter in production
We've all experienced the frustration of an app that only syncs when you open it. In real-world scenarios—whether it's a travel app downloading offline maps, an e-commerce app syncing cart updates, or a CRM syncing contact changes—users assume work continues in the background. The system gives us that capability, but we have to respect its boundaries.
The iOS background execution model is a trade-off: the system grants limited runtime in exchange for battery life. Your job is to fit meaningful work into those windows, handle interruptions gracefully, and ensure your app doesn't get throttled or backgrounded permanently.
BGTaskScheduler for refresh and processing
BGTaskScheduler handles two main background task types: BGProcessingTask for longer processing (up to 30 seconds, though often interrupted), and BGAppRefreshTask for shorter refresh operations (typically 30 seconds or less). Both require declaring capabilities in your Info.plist and requesting user notification permission.
Let's walk through a real scenario: a ride-hailing app needs to check for new ride requests even when the user isn't actively using it.
import BackgroundTasks
class RideRefreshManager {
static let appRefreshTaskIdentifier = "com.ridehailing.refresh"
static let processingTaskIdentifier = "com.ridehailing.processing"
static func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: appRefreshTaskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule app refresh: \(error)")
}
}
static func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: appRefreshTaskIdentifier,
using: DispatchQueue.global()
) { task in
handleAppRefresh(task as! BGAppRefreshTask)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskIdentifier,
using: DispatchQueue.global()
) { task in
handleProcessing(task as! BGProcessingTask)
}
}
private static func handleAppRefresh(_ task: BGAppRefreshTask) {
scheduleAppRefresh()
let queue = DispatchQueue.global()
var backgroundTaskID = UIBackgroundTaskIdentifier.invalid
backgroundTaskID = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTaskID)
task.setTaskCompleted(success: false)
}
queue.async {
let urlSession = URLSession(configuration: .default)
var request = URLRequest(url: URL(string: "https://api.ridehailing.com/check-rides")!)
request.timeoutInterval = 10
let dataTask = urlSession.dataTask(with: request) { data, response, error in
defer {
UIApplication.shared.endBackgroundTask(backgroundTaskID)
}
if let error = error {
print("Error checking rides: \(error)")
task.setTaskCompleted(success: false)
return
}
if let data = data {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: NSNotification.Name("NewRidesAvailable"),
object: data
)
}
}
task.setTaskCompleted(success: true)
}
dataTask.resume()
}
}
private static func handleProcessing(_ task: BGProcessingTask) {
scheduleProcessing()
var backgroundTaskID = UIBackgroundTaskIdentifier.invalid
backgroundTaskID = UIApplication.shared.beginBackgroundTask {
UIApplication.shared.endBackgroundTask(backgroundTaskID)
task.setTaskCompleted(success: false)
}
DispatchQueue.global().async {
let startTime = Date()
let timeout: TimeInterval = 25
var processedCount = 0
let rideIds = fetchPendingRides()
for rideId in rideIds {
if Date().timeIntervalSince(startTime) > timeout {
print("Processing timeout reached, saving progress")
break
}
processRideUpdate(rideId)
processedCount += 1
}
print("Processed \(processedCount) rides")
UIApplication.shared.endBackgroundTask(backgroundTaskID)
task.setTaskCompleted(success: true)
}
}
private static func scheduleProcessing() {
let request = BGProcessingTaskRequest(identifier: processingTaskIdentifier)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule processing: \(error)")
}
}
private static func fetchPendingRides() -> [String] {
return []
}
private static func processRideUpdate(_ rideId: String) {
}
}
Call RideRefreshManager.registerBackgroundTasks() in your app delegate's didFinishLaunchingWithOptions, and schedule tasks when your app enters the background or at launch.
Background URLSession for uploads
For longer-running network operations like uploading photos or logs, don't use BGTaskScheduler. Instead, use a background URLSession, which runs independently and survives app termination.
Consider an SaaS app that lets users upload large survey responses with photos. A background URLSession ensures uploads complete even if the user leaves the app or it gets suspended.
class SurveyUploadManager: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
static let shared = SurveyUploadManager()
private var backgroundSession: URLSession?
private var completionHandler: (() -> Void)?
func setupBackgroundSession() {
let config = URLSessionConfiguration.background(
withIdentifier: "com.surveyapp.upload"
)
config.waitsForConnectivity = true
config.isDiscretionary = false
backgroundSession = URLSession(
configuration: config,
delegate: self,
delegateQueue: nil
)
}
func uploadSurvey(_ surveyId: String, filePath: String) {
guard let session = backgroundSession else {
setupBackgroundSession()
return
}
guard let fileURL = URL(string: filePath) else { return }
var request = URLRequest(
url: URL(string: "https://api.surveyapp.com/upload")!
)
request.httpMethod = "POST"
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.setValue(surveyId, forHTTPHeaderField: "X-Survey-ID")
let task = session.uploadTask(with: request, fromFile: fileURL)
task.resume()
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
self.completionHandler?()
self.completionHandler = nil
}
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error = error {
print("Upload failed: \(error)")
} else {
print("Upload completed successfully")
}
}
}
In your app delegate, store the completion handler for when background transfers finish:
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
SurveyUploadManager.shared.backgroundSession?.delegateQueue.addOperation {
DispatchQueue.main.async {
completionHandler()
}
}
}
Limited time — keep tasks short
This is where most implementations fail. iOS grants 30 seconds or less for BGAppRefreshTask and typically 30 seconds (often interrupted sooner) for BGProcessingTask. Your code must check the wall clock and exit gracefully.
A common mistake: wrapping a 5-minute sync operation in a background task and assuming it will complete. It won't. Instead, break work into chunks and schedule multiple tasks if needed.
private static func handleAppRefresh(_ task: BGAppRefreshTask) {
scheduleAppRefresh()
let startTime = Date()
let deadline: TimeInterval = 25
task.expirationHandler = {
print("Task expired, wrapping up")
task.setTaskCompleted(success: false)
}
DispatchQueue.global().async {
let items = fetchItemsToSync()
var syncedCount = 0
for item in items {
if Date().timeIntervalSince(startTime) > deadline {
print("Approaching deadline, scheduling next sync")
break
}
syncItem(item)
syncedCount += 1
}
print("Synced \(syncedCount) items")
task.setTaskCompleted(success: true)
}
}
private static func fetchItemsToSync() -> [String] {
return []
}
private static func syncItem(_ item: String) {
}
Always set an expirationHandler on your task. The system calls it when time is up, giving you a chance to stop work cleanly and call setTaskCompleted.
Test with the debugger commands
Background tasks are hard to test in the simulator and real device without the right tools. Use these commands in the debugger console to simulate task execution.
For BGAppRefreshTask:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.ridehailing.refresh"]
For BGProcessingTask:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.ridehailing.processing"]
Paste these into the LLDB debugger while your app is paused. The task will execute immediately, letting you test timeout handling, data persistence, and error recovery without waiting hours.
For a more thorough test, use the Environment variable SIMULATOR_DEVICE_NAME to test on specific device configurations, and monitor system logs with log stream --predicate 'eventMessage contains "BackgroundTask"' to see what the system is doing.
Common mistakes
- Assuming background time is unlimited. Set a timer, check elapsed time frequently, and exit before the 30-second window closes. The system will kill your task otherwise.
- Not handling network failures. Background tasks often run on cellular or weak WiFi. Always wrap network calls in error handling and retry logic scheduled for later.
- Scheduling tasks too frequently. The system throttles apps that request background execution constantly. Space requests at least 15 minutes apart unless there's genuine user-facing value.
- Not declaring capabilities in Info.plist. Your tasks won't run unless you add
com.apple.developer.backgroundtasksto your entitlements and list task identifiers. - Calling setTaskCompleted multiple times. Once you call it, don't call it again. The second call crashes silently and breaks your background execution.
- Using background tasks for critical work. Background tasks are unreliable. If users must see the result immediately, use foreground operations with a UI prompt.
Key takeaways
- Use
BGAppRefreshTaskfor quick refresh operations andBGProcessingTaskfor heavier processing, each with roughly 30 seconds of runtime. - For uploads and downloads, use background
URLSessioninstead—it survives app termination and runs independently. - Always check elapsed time and exit gracefully before the deadline. Set an
expirationHandlerto catch timeout conditions. - Test background tasks with LLDB debugger commands rather than waiting for real background execution, which is slow and unpredictable.
- Don't schedule background work too frequently; the system will throttle your app if it detects abuse.
- Treat background tasks as a convenience, not a guarantee. Build your app so it works fine when background execution is unavailable.






Comments
Loading comments…