Skip to main content

One post tagged with "nsurlsessiond"

View All Tags

Resolving Background URLSession Stalls on iOS

Published: · 12 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

Background downloads are supposed to “just work” after you hand them off to the system. Yet plenty of teams end up with URLSession background download not working in production: transfers stall for hours, handleEventsForBackgroundURLSession not called, errors carry NSURLErrorBackgroundTaskCancelledReasonKey, or nsurlsessiond spikes CPU and drains battery. This guide explains how background URLSession actually runs on iOS, why it stalls, and how to implement a robust, production-ready solution using modern Swift, SwiftUI, and Apple-recommended patterns.

Prerequisites

  • iOS 15+ (tested on iOS 17+), Xcode 15+, Swift 5.9+
  • App uses the modern app lifecycle (SwiftUI) or UIKit, with an AppDelegate available for background session events
  • Background Modes capability enabled in the target (see Setup)

What actually runs your background downloads

When you create a background URLSession (URLSessionConfiguration.background(withIdentifier:)), iOS hands the work to a system daemon, nsurlsessiond. That daemon:

  • Performs the transfer even if your app is suspended or terminated
  • Relaunches your app in the background when events are ready (e.g., a download finished)
  • Calls AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:) to give your process a short window to handle callbacks
  • Expects you to recreate a URLSession with the same identifier and the same delegate to receive completion events
  • Kills your process if you don’t call the provided completionHandler within the allowed time

Stalls happen when any of these expectations are violated or when network, policy, or resource limits prevent progress.

Symptoms and root causes

1) Troubleshooting: “URLSession background download not working”

Common root causes:

  • You didn’t recreate the background session with the same identifier on next launch
  • You never implemented or wired up AppDelegate.handleEventsForBackgroundURLSession
  • Your delegate was deallocated or not retained
  • You created multiple background sessions with different identifiers and lost track of tasks
  • You’re doing long-running work in delegate callbacks and exceeding the background time limit
  • Constrained network policies (Low Data Mode, expensive or constrained networks) block progress because of configuration flags

2) handleEventsForBackgroundURLSession not called

  • SwiftUI-only apps forget to provide an AppDelegate. With scenes, this must still be implemented on the application delegate, not in a scene delegate.
  • You didn’t recreate URLSession(configuration: backgroundID, delegate: …) before the system tries to deliver events.
  • You failed to keep the completion handler to call later after urlSessionDidFinishEvents(forBackgroundURLSession:).

3) NSURLErrorBackgroundTaskCancelledReasonKey appears in error.userInfo

If a background transfer completes with error code NSURLErrorCancelled (-999), the userInfo may include NSURLErrorBackgroundTaskCancelledReasonKey indicating why the system canceled the task. Typical reasons include:

  • User force-quit the app
  • Background updates disabled (system policy)
  • Insufficient system resources (memory, disk, power)

Inspect and log this key to understand cancellations and adjust behavior (e.g., retry strategy, deferring large work, asking the user to reopen the app).

4) iOS background download task time limit

  • The system gives your app a limited time window (typically tens of seconds) to handle callbacks after being relaunched for background events.
  • Don’t do heavy decompression, parsing, or database work in download delegates. Move/rename the file and return. Schedule BGProcessingTask for heavy post-processing.
  • If you exceed the limit, the system kills your process and may stop delivering further events, which looks like “stalled” downloads.

5) nsurlsessiond high CPU

  • Flooding the daemon with too many concurrent tasks, rapid-fire retries, or thrashing resume data can send CPU through the roof.
  • Multiple background sessions per app compete with each other and raise overhead. Use one shared background session.
  • Misconfigured policies causing repeated failures (e.g., trying to download on a constrained or expensive network when disallowed) can cause retry loops.

Setup checklist (current best practices)

  • Enable “Background Modes” capability. For background transfers, enable Background fetch. This ensures the system treats your app as capable of completing work after relaunch.
  • Use exactly one background session across your app, with a stable identifier.
  • Use URLSessionDownloadTask for large files. Move the file quickly in didFinishDownloadingTo and return.
  • Keep the delegate alive for the entire process lifetime.
  • Recreate the session early on launch (App start), before events arrive.
  • In SwiftUI apps, bridge an AppDelegate via @UIApplicationDelegateAdaptor to receive handleEventsForBackgroundURLSession.
  • Limit concurrency. Let the system schedule (isDiscretionary = true) for large background tasks.

A production-ready background download implementation (Swift, iOS 15+)

This reference shows:

  • A singleton BackgroundSessionManager owning a single background URLSession
  • Proper AppDelegate wiring for handleEventsForBackgroundURLSession and urlSessionDidFinishEvents
  • Minimal file handling in the delegate
  • Async event stream for progress
  • A sanity pass that reconciles tasks on launch with getAllTasks
  • Hooks for BGProcessingTask to offload heavy post-processing

BackgroundSessionManager

import Foundation
import OSLog

final class BackgroundSessionManager: NSObject {
static let shared = BackgroundSessionManager()

// Use a reverse-DNS, stable identifier. Never change this after release.
private let identifier = "com.example.yourapp.backgroundtransfer"

// Publish simple progress updates (replace with Combine or your own bus as needed)
struct DownloadProgress {
let url: URL
let bytesWritten: Int64
let totalBytesWritten: Int64
let totalBytesExpected: Int64
}

// Simple callback hooks (swap for Combine/AsyncStream as desired)
var onProgress: ((DownloadProgress) -> Void)?
var onFinished: ((URL, URL) -> Void)? // (remoteURL, fileLocation)
var onError: ((URL, Error) -> Void)?

// The completion handler passed from AppDelegate when events are delivered
private var backgroundEventsCompletionHandler: (() -> Void)?

private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: identifier)
// Let the system optimally schedule background work
config.isDiscretionary = true
// Helpful on spotty networks; the daemon waits for a better connection rather than instantly failing
config.waitsForConnectivity = true
// Respect cellular and Low Data Mode by default; adjust if your app’s UX requires otherwise
config.allowsCellularAccess = true
config.allowsExpensiveNetworkAccess = true
config.allowsConstrainedNetworkAccess = false

// Keep connections reasonable; background daemon manages concurrency too
config.httpMaximumConnectionsPerHost = 2

// Delegate queue: nil => a serial operation queue created by the system
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

private override init() {
super.init()
}

// Called early on app launch to ensure the background session exists before events arrive.
func configure() {
// Reconciliation builds local state for existing tasks after relaunch.
reconcileExistingTasks()
}

// Start a new download. In production, track the task in persistent storage (Core Data/SQLite).
@discardableResult
func startDownload(from url: URL) -> URLSessionDownloadTask {
let task = session.downloadTask(with: url)
task.earliestBeginDate = nil // or set if you want to schedule in the future
task.resume()
return task
}

// Used by AppDelegate when iOS relaunches the app to deliver events
func setBackgroundEventsCompletionHandler(_ handler: @escaping () -> Void) {
backgroundEventsCompletionHandler = handler
}

private func reconcileExistingTasks() {
session.getAllTasks { tasks in
// Useful after relaunch or crash: tasks continue in the daemon;
// rebuild UI state, attach observers, etc.
if tasks.isEmpty { return }
os_log("Reconciled %d existing background tasks", log: .default, type: .info, tasks.count)
}
}

// Call this when you're done handling all background URLSession events.
private func finishEventsIfPossible() {
// If there are no pending delegate callbacks, call the AppDelegate completion handler.
session.getAllTasks { [weak self] tasks in
guard let self = self else { return }
let hasRunning = tasks.contains { $0.state == .running }
if !hasRunning, let handler = self.backgroundEventsCompletionHandler {
self.backgroundEventsCompletionHandler = nil
handler()
}
}
}
}

extension BackgroundSessionManager: URLSessionDownloadDelegate, URLSessionTaskDelegate {
// Progress updates
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64) {
guard let sourceURL = downloadTask.originalRequest?.url else { return }
onProgress?(DownloadProgress(
url: sourceURL,
bytesWritten: bytesWritten,
totalBytesWritten: totalBytesWritten,
totalBytesExpected: totalBytesExpectedToWrite
))
}

// File finished downloading to a temporary location. Move it quickly and return.
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
guard let sourceURL = downloadTask.originalRequest?.url else { return }
do {
let destination = try self.makeDestinationURL(for: sourceURL)
// Remove existing file if present
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: location, to: destination)

// Don’t back up to iCloud; large media can blow user’s quota
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try? destination.setResourceValues(resourceValues)

onFinished?(sourceURL, destination)
} catch {
onError?(sourceURL, error)
}
}

// Final completion (success or failure)
func urlSession(_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?) {
guard let sourceURL = task.originalRequest?.url else { return }
if let error = error as NSError? {
// Decode background cancellation reason if present
if error.code == NSURLErrorCancelled,
let reason = error.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? NSNumber {
let message = Self.describeBackgroundCancelReason(reason.intValue)
os_log("Background task for %{public}@ cancelled: %{public}@", "\(sourceURL)", message)
}
onError?(sourceURL, error)
}
// After all delegate callbacks finish and no tasks are running, tell the system we’re done
finishEventsIfPossible()
}

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
// Called when the daemon delivered all pending delegate calls for this session.
// We might still have running tasks; finishEventsIfPossible() handles that.
finishEventsIfPossible()
}

private static func describeBackgroundCancelReason(_ value: Int) -> String {
// Public docs expose the key; numeric values are implicitly defined by Apple.
// Here are commonly observed reasons:
switch value {
case 1: return "User force-quit the app"
case 2: return "Background updates disabled"
case 3: return "Insufficient system resources"
default: return "Unknown reason (\(value))"
}
}

private func makeDestinationURL(for sourceURL: URL) throws -> URL {
let caches = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let downloadsDir = caches.appendingPathComponent("BackgroundDownloads", isDirectory: true)
try FileManager.default.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
let filename = sourceURL.lastPathComponent.isEmpty ? UUID().uuidString : sourceURL.lastPathComponent
return downloadsDir.appendingPathComponent(filename)
}
}

AppDelegate wiring (UIKit or SwiftUI lifecycle)

The single most common reason handleEventsForBackgroundURLSession not called is not having an AppDelegate method at all, especially in a SwiftUI app.

import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
BackgroundSessionManager.shared.configure()
return true
}

// Crucial for background transfers
func application(_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
// Ensure you recreate the session with the same identifier
BackgroundSessionManager.shared.setBackgroundEventsCompletionHandler(completionHandler)
// Accessing shared will initialize the session if needed; configure() already did this on launch
}
}

SwiftUI entry point:

import SwiftUI

@main
struct YourApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

SwiftUI usage example

import SwiftUI

struct ContentView: View {
@State private var status: String = "Idle"
private let manager = BackgroundSessionManager.shared

var body: some View {
VStack(spacing: 24) {
Text(status).font(.caption)

Button("Download Sample File") {
let url = URL(string: "https://speed.hetzner.de/100MB.bin")!
manager.startDownload(from: url)
}
}
.padding()
.onAppear {
manager.onProgress = { progress in
let pct = Double(progress.totalBytesWritten) / Double(progress.totalBytesExpected) * 100.0
status = String(format: "Downloading %.1f%%", pct)
}
manager.onFinished = { _, fileURL in
status = "Finished at \(fileURL.lastPathComponent)"
}
manager.onError = { _, error in
status = "Error: \(error.localizedDescription)"
}
}
}
}

Offload heavy post-processing with BGProcessingTask

For decompression, parsing, or database writes that exceed the iOS background download task time limit, schedule a BGProcessingTask. This avoids getting killed while handling URLSession callbacks.

import BackgroundTasks

enum BackgroundWork {
static let identifier = "com.example.yourapp.heavyprocessing"

static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
handle(task: task as! BGProcessingTask)
}
}

static func schedule() {
let request = BGProcessingTaskRequest(identifier: identifier)
request.requiresNetworkConnectivity = false
request.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(request)
}

private static func handle(task: BGProcessingTask) {
// Do your heavy file/database work here
task.expirationHandler = {
// Clean up if time is about to expire
}
// Call task.setTaskCompleted(success:) when finished
// ...
task.setTaskCompleted(success: true)
}
}

Call BackgroundWork.register() early (e.g., in AppDelegate.didFinishLaunching). After finishing a download in the URLSession delegate, schedule BackgroundWork.schedule() for any large file processing.

Configuration guidance and trade-offs

  • Use one background session per app. Multiple background sessions multiply the system overhead and complicate event routing.
  • config.isDiscretionary = true lets iOS optimize transfers (e.g., defer large downloads to Wi‑Fi, while the device is charging).
  • waitsForConnectivity = true reduces spurious failures when network is temporarily unavailable.
  • allowsExpensiveNetworkAccess and allowsConstrainedNetworkAccess should reflect your UX:
    • If users expect downloads on cellular and in Low Data Mode, explicitly set them to true.
    • Otherwise, keep constrained access off to respect user policies.
  • httpMaximumConnectionsPerHost: A low number prevents saturating the daemon and helps avoid nsurlsessiond high cpu.

Debugging and testing background transfers

  • Validate handleEventsForBackgroundURLSession wiring

    • Confirm the AppDelegate method is present and fires by logging on entry.
    • Ensure you recreated the URLSession with the exact same identifier before events are delivered.
    • Keep and call the provided completionHandler only after urlSessionDidFinishEvents(forBackgroundURLSession:) indicates you’re done.
  • Inspect errors and userInfo

    • When you see NSURLErrorCancelled (-999), check error.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey].
    • Log reasons and correlate with user behavior (force-quit), device policy (background refresh disabled), or resource pressure.
  • Observe nsurlsessiond behavior

    • Use Console.app and filter for your bundle ID and “nsurlsessiond”.
    • Instruments: use “Network” and “Energy Log” to spot retry loops, excessive wakeups, and CPU spikes.
  • Simulate challenging networks

    • Use Xcode’s “Network Link Conditioner” (or scheme-based Network Conditions) to inject latency, packet loss, and bandwidth constraints.
    • Toggle Low Data Mode on the device and verify your allowsConstrainedNetworkAccess behavior.
  • Kill and relaunch testing

    • Start a download, force-quit the app, and confirm it finishes and relaunches your app to deliver events.
    • If events don’t arrive, check the identifier mismatch and AppDelegate wiring.
  • Reconcile tasks on launch

    • Always call session.getAllTasks on startup and rebuild UI state; background tasks keep running even if your app was terminated.

Performance and reliability best practices

  • Keep delegate work minimal:

    • In didFinishDownloadingTo, move/rename the file and return immediately.
    • For large post-processing, schedule BGProcessingTask to avoid hitting the iOS background download task time limit.
  • Avoid retry storms:

    • Back off with exponential delays on transient HTTP failures.
    • Don’t auto-retry NSURLErrorCancelled when NSURLErrorBackgroundTaskCancelledReasonKey indicates user or policy-driven cancellation.
  • Manage concurrency:

    • Limit parallel background downloads. 2–3 concurrent downloads is often sufficient; queue the rest.
    • Let the system optimize with isDiscretionary for large, non-urgent transfers.
  • Storage hygiene:

    • Write to Caches, not Documents, for re-downloadable content.
    • Mark large files as excludedFromBackup to respect iCloud quotas.
    • Handle low disk space errors and clean old downloads.
  • Security:

    • Respect App Transport Security; use HTTPS.
    • Consider certificate pinning for sensitive content.

Troubleshooting checklist

  • “URLSession background download not working”

    • One background session with a stable identifier
    • Recreate the session on every launch before events arrive
    • AppDelegate implements application(_:handleEventsForBackgroundURLSession:completionHandler:)
    • Keep and call the completion handler after urlSessionDidFinishEvents
    • Limit delegate work; offload heavy tasks to BGProcessingTask
  • handleEventsForBackgroundURLSession not called

    • Using SwiftUI? Add @UIApplicationDelegateAdaptor and implement the method on AppDelegate
    • Identifier mismatch or multiple identifiers in different builds/sandbox targets
    • Delegate not retained or session not created early enough
  • NSURLErrorBackgroundTaskCancelledReasonKey

    • Inspect and log reason to understand policy/user/system cancellations
    • Adjust retry and scheduling accordingly
  • iOS background download task time limit

    • Keep callback work small; schedule BGProcessingTask for heavy lifting
    • Ensure you eventually call the AppDelegate completion handler
  • nsurlsessiond high cpu

    • Reduce parallel tasks and combined background sessions
    • Fix retry loops and honor network constraints
    • Use isDiscretionary and waitsForConnectivity to avoid wasteful wakeups

Key takeaways

  • Background URLSession is reliable when you follow the contract: one stable background session, correct AppDelegate wiring, minimal delegate work, and timely completion.
  • Inspect NSURLErrorBackgroundTaskCancelledReasonKey to understand cancellations and inform retries.
  • Respect network constraints and user policies to prevent stalls and nsurlsessiond high cpu.
  • Offload heavy processing with BGProcessingTask to stay within the iOS background download task time limit.
  • If you’re still seeing URLSession background download not working, start with the wiring and identifier checks, then move on to policies (discretionary, constrained/expensive), concurrency, and retry behavior.

Next steps

  • Integrate the BackgroundSessionManager skeleton into your app and wire AppDelegate events.
  • Add persistent tracking (Core Data) for tasks and robust retry/backoff logic.
  • Instrument with Console and Instruments to validate behavior under real-world network conditions.