Downloading files in background with URLSessionDownloadTask

by @ralfebert · published July 26, 2021
Xcode 12 & iOS 14
Advanced iOS Developers

This article demonstrates how to use ↗ URLSessionDownloadTask to download files in the background so that they can completed even if the app is terminated - the actual download process will run outside of the app process by the system so it can continue when the app is terminated. This works exactly the same for uploads with URLSessionUploadTask. The example also shows how to implement progress monitoring for multiple tasks running in parallel:

Starting downloads

To start a download that can be completed in background without the app running, create a URLSessionConfiguration for background processing. The identifier will identify the URLSession: if the process is terminated and later restarted, you can get the “same” URLSession f.e. to ask about the progress of downloads in progress:

let config = URLSessionConfiguration.background(withIdentifier: "com.example.DownloadTaskExample.background")

Then create a URLSession object. This can be observed using a URLSessionTaskDelegate:

let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())

Caution: If there already exists an URLSession with the same id, it doesn't create a new URLSession object but returns the existing one with the old delegate object attached. It will give you a warning about this behaviour “A background URLSession with identifier ... already exists!”. So make sure you only create the session once or handle this accordingly.

Then create a URLSessionDownloadTask and resume it:

let url = URL(string: "https://example.com/example.pdf")!
let task = session.downloadTask(with: url)
task.resume()

Monitoring progress for download tasks

Completion handler blocks are not supported in background sessions - the URLSession delegate has to be used for observing the download. When the download is completed, it is written to the Caches directory of the application and the URLSessionDownloadDelegate is notified.

Because of the impossibility to change the delegate of the URLSession once it was created and to observe the progress of downloads from older processes, it pays off to create an application wide object to manage such downloads:

class DownloadManager: NSObject, ObservableObject {
    static var shared = DownloadManager()

    private var urlSession: URLSession!
    @Published var tasks: [URLSessionTask] = []

    override private init() {
        super.init()

        let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")

        // Warning: Make sure that the URLSession is created only once (if an URLSession still
        // exists from a previous download, it doesn't create a new URLSession object but returns
        // the existing one with the old delegate object attached)
        urlSession = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())

        updateTasks()
    }

    func startDownload(url: URL) {
        let task = urlSession.downloadTask(with: url)
        task.resume()
        tasks.append(task)
    }

    private func updateTasks() {
        urlSession.getAllTasks { tasks in
            DispatchQueue.main.async {
                self.tasks = tasks
            }
        }
    }
}

extension DownloadManager: URLSessionDelegate, URLSessionDownloadDelegate {
    func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten _: Int64, totalBytesExpectedToWrite _: Int64) {
        os_log("Progress %f for %@", type: .debug, downloadTask.progress.fractionCompleted, downloadTask)
    }

    func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        os_log("Download finished: %@", type: .info, location.absoluteString)
        // The file at location is temporary and will be gone afterwards
    }

    func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error {
            os_log("Download error: %@", type: .error, String(describing: error))
        } else {
            os_log("Task finished: %@", type: .info, task)
        }
    }
}

What happens if the app is not running anymore?

First of all, make sure you quit the app using the Stop button from Xcode. If you force-quit the app by swiping up from the app switcher the system cancels all background tasks:

Swiping in the application switcher cancels all background tasks

If the app is suspended (the app process is terminated) while a download is still in progress and then started again, if the download is still active, you can access the URLSession with the same identifier and it will resume notifications about the progress of the download.

But, if a task is finished while the app is not running, the system will launch the app in the background and call the AppDelegate method handleEventsForBackgroundURLSession. This is the only chance to get notified about such a download:

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
        os_log("handleEventsForBackgroundURLSession for %@", type: .info, identifier)
        completionHandler()
    }
}

This can be tricky to debug. The example app writes those events to a UserDefault property so you can see that the notification actually happened. It's also possible to see this using Debug > Attach to Process by PID or Name...:

Debug process in background