Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 035 / 100

Background tasks in iOS: real-world lessons and examples

Work when the app is not active.

Expona Admin8 min read

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.

swift
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.

swift
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:

swift
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.

swift
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:

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

For BGProcessingTask:

swift
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.backgroundtasks to 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 BGAppRefreshTask for quick refresh operations and BGProcessingTask for heavier processing, each with roughly 30 seconds of runtime.
  • For uploads and downloads, use background URLSession instead—it survives app termination and runs independently.
  • Always check elapsed time and exit gracefully before the deadline. Set an expirationHandler to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles