Keep downloading with a background session

29 Apr 2025 20 min read

In the distant past, when dinosaurs still roamed the land, iOS focused solely on what the user was doing, ensuring a responsive user experience on performance-constrained devices. While beneficial for users, this focus meant that as soon as an app went into the background, iOS suspended it. However, as the years passed, devices have become more powerful and energy-efficient. Unlike our prehistoric predecessors πŸ¦–, iOS adapted to meet its changing environment. iOS now allows apps to perform limited background work while maintaining foreground responsiveness.

One form of permitted background work is being able to continue download or upload network requests when an app has been suspended or even terminated. These network requests are collectively known as background-transfers - background being where the network request happens and transfer being the collective terms for download and upload requests. Support for background-transfers was introduced in iOS 7. While not a recent change, it remains a powerful tool.

This post will explore background-transfers and how we can enhance the overall experience of our apps by adding support for them.

Photo of a user leaving

This post will gradually build up to a working example. But I get it, this is exciting stuff 😎, so if you are unable to contain that excitement, then head on over to the completed example and take a look at BackgroundDownloadService and AppDelegate to see how things end up.

Different types of sessions

When Apple introduced the URLSession suite of classes, they addressed a number of the shortfalls that were present in the NSURLConnection networking stack. One particular pain point of an NSURLConnection networking stack was that there was no built-in way to group related types of requests together - each request in a group had to be individually configured. URLSession changed this by moving that group configuration to a session. A session represents a configurable object that handles the configuration and coordination of network requests. An app can have multiple sessions. Requests scheduled within a session automatically inherit its configuration. While useful for app developers to group requests, this session-level configuration also allowed iOS to offer more functionality as standard, enabling support for background-transfers on special background sessions.

There are three flavours of session:

  1. Default - supports all URLSessionTask subtypes for making a network request. These network requests can only be performed with the app in the foreground.
  2. Ephemeral - similar to default but does not persist caches, cookies, or credentials to disk.
  3. Background - supports all URLSessionTask subtypes for making a network request. URLSessionDataTask can only be performed with the app in the foreground whereas URLSessionDownloadTask and URLSessionUploadTask can be performed with the app in the foreground, suspended or terminated.

Each session has its own use case; let's explore how we can use a background session to enable our transfers to continue when the user leaves the app.

So how do background-transfers work? πŸ•΅οΈ

When scheduling a transfer on a background session, that transfer is passed to the nsurlsessiond daemon to be processed. As nsurlsessiond lives outside of the lifecycle of any given app, any transfer scheduled on nsurlsessiond will continue even if the app that the transfer belongs to is suspended/terminated. Once a background-transfer is complete, if the app has been suspended/terminated, iOS will wake the app in the background and allow the app to perform any post-transfer processing (within a limited time frame); if the app is in the foreground, control will be passed back to the app as if the transfer has been scheduled on a default or ephemeral session (without the limited time frame for post-transfer processing).

You might be thinking:

"That sounds pretty amazing! Why isn't this the default behaviour for transfers?"

Well, there are a few reasons why this isn't the default behaviour:

  • Resource Management - excessive background processing can drain battery life and consume unexpected bandwidth; Apple wants to ensure that as app developers, we use it responsibly and only where it's adding value.
  • Programming Complexity - implementing background-transfers requires forgoing the convenient async/await and/or closure-based methods of URLSession and instead requires conforming to URLSessionDownloadDelegate to receive updates about the transfers, adding complexity when compared to foreground-only sessions.

You may have watched Efficiency awaits: Background tasks in SwiftUI from WWDC and be thinking that you can use the convenient async/await and/or closure-based methods however if you do so your app will crash. The functionality shown in that walkthrough sadly never made it into production as described in this Apple Developer forum thread.

Now that we know more about background-transfers let's see how we can add support for background-transfers to our project.

Setting up the project

The app must be granted additional permissions to enable background-transfers:

  1. Open the Signing & Capabilities tab in the target.
  2. Add the Background Modes capability.
  3. In the newly added Background Modes section, select the Background fetch and Background processing checkboxes.

After completing these steps, your settings should look like this:

Screenshot of configuring background modes

With the project correctly configured, let's look at how to perform a background download.

Let's get downloading

Our background-transfer layer has five primary responsibilities:

  1. Configuring a background session.
  2. Scheduling the download request.
  3. Responding to download updates.
  4. Processing any completed downloads by moving files to a permanent location on the file system or reporting an error.
  5. Recovering from a terminated/suspended state to process completed downloads.

These responsibilities are handled inside BackgroundDownloadService. As URLSessionDownloadDelegate mostly doesn't differentiate between downloads that complete when the app is in the foregrounded, suspended or terminated state, all three possible states need to be handled together.

The steps involved in handling a download that completes with the app in the foreground state are slightly different than a download that completes with the app in the suspended/terminated state:

Foregrounded Suspended/Terminated
  1. A download is requested on BackgroundDownloadService.
  1. A download is requested on BackgroundDownloadService.
  1. The requested download's metadata is stored.
  1. The requested download's metadata is stored.
  1. The download is kicked off.
  1. The download is kicked off.
  1. BackgroundDownloadService is informed of the outcome of the download via URLSessionDownloadDelegate.
  1. The app is woken up, and the app-switcher-preview closure is passed from AppDelegate to BackgroundDownloadService.
  1. Metadata is retrieved for the download.
  1. BackgroundDownloadService is informed of the outcome of the download via URLSessionDownloadDelegate.
  1. The downloaded content is moved to its permanent local file system location.
  1. Metadata is retrieved for the download.
  1. Continuation is resumed.
  1. The downloaded content is moved to its permanent local file system location.
  1. BackgroundDownloadService is informed that all downloads are completed.
  1. App-switcher-preview closure is triggered.

Don't worry if any part of the above steps doesn't make sense; each part will be explored in more depth as the solution is built up.

Before looking at BackgroundDownloadService, let's take a short detour and look at a supporting type that will used in BackgroundDownloadService:

enum BackgroundDownloadError: Error {
    case cancelled
    case unknownDownload
    case fileSystemError(_ underlyingError: Error)
    case clientError(_ underlyingError: Error)
    case serverError(_ underlyingResponse: URLResponse?)
}

BackgroundDownloadError is an enum that conforms to the Error protocol and will be used to provide details about the unhappy download paths through BackgroundDownloadService.

Detour over πŸ—ΊοΈ.

Let's build the skeleton of BackgroundDownloadService:

actor BackgroundDownloadService: NSObject { // 1
    // 2
    private lazy var session: URLSession = {
        // 3
        let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session")

        // 4
        configuration.isDiscretionary = false

        // 5
        configuration.sessionSendsLaunchEvents = true
        let session = URLSession(configuration: configuration,
                                 delegate: self,
                                 delegateQueue: nil)

        return session
    }()

    // MARK: - Singleton

    // 6
    static let shared = BackgroundDownloadService()

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

Here's what we did:

  1. As mentioned earlier, background session downloads don't support the async/await or closure methods on URLSession instead updates are provided via the sessions delegate. BackgroundDownloadService needs to be a subclass of NSObject to conform to URLSessionDownloadDelegate (we will add conformance to URLSessionDownloadDelegate later in an extension).
  2. There exists a cyclic dependency between BackgroundDownloadService and URLSession where BackgroundDownloadService is responsible for not only creating the URLSession instance but also being its delegate. However, the delegate of URLSession can only be set at initialisation of the URLSession. Both these conditions cannot be satisfied. To break the cyclic dependency between BackgroundDownloadService and URLSession, session is lazy stored property so the URLSession instance won't be created until its first use which is after BackgroundDownloadService has been initialised.
  3. A session's configuration is defined through a URLSessionConfiguration instance, which offers numerous configuration options. Every URLSessionConfiguration for a background session requires a unique identifier within that app's lifecycle. If an existing URLSession already has that identifier, iOS returns the previously created URLSession instance. The session identifier plays a vital role in ensuring that a download can survive app termination - for any download update, iOS will attempt to find a URLSession instance with the same identifier that the download was kicked off on to inform of the update; that found URLSession instance does not need to be the same instance that was used to kick off the download. To ensure the app always has a URLSession that can pick up any download updates, session is consistently configured with the same identifier.
  4. Setting isDiscretionary to false tells iOS that any downloads scheduled on this session should be started as soon as possible rather than at the discretion of iOS.
  5. Setting sessionSendsLaunchEvents to true tells iOS that any downloads scheduled on this session can launch the app if it is suspended/terminated.
  6. BackgroundDownloadService is singleton to ensure that any background download updates are funnelled into the same type that holds the metadata of that download.
  7. init() is private to ensure that another instance of BackgroundDownloadService cannot be created.

So far, we have configured a session for performing background downloads but can't yet download anything.

Two pieces of information are required for each download:

  1. Remote URL - where the content to be downloaded is.
  2. File path URL - where the downloaded content should be stored on the local file system.

Where we kick off a download and where we handle the outcome of that download are split over a delegate callback. So we need a way to cache the file path URL when that download is kicked off and then retrieve it when that download completes:

actor BackgroundDownloadService: NSObject {
    // Omitted other properties

    // 1
    private let persistentStore = UserDefaults.standard
}

Here's what we did:

  1. As a download may complete after the app has been terminated and relaunched, details of where the downloaded content should end up on the local file system must be persisted outside of memory. UserDefaults is a simple and effective means of persisting that information. The remote URL will be the key, with the file path URL being the value.

As mentioned, background sessions don't support the async/await methods on URLSession. However, an async/await based interface is perfect for downloading content where the caller is really only interested in having the downloaded content or an error. We can recreate that convenient async/await interface by making use of a continuation to transform the delegate-based approach into an async/await approach:

actor BackgroundDownloadService: NSObject {
    // Omitted other properties

    // 1
    private var inMemoryStore = [String: CheckedContinuation]()

    // MARK: - Download

    func download(from fromURL: URL,
                  to toURL: URL) async throws -> URL {
        // 2
        return try await withCheckedThrowingContinuation { continuation in
            // 3
            inMemoryStore[fromURL.absoluteString] = continuation
            persistentStore.set(toURL, forKey: fromURL.absoluteString)

            // 4
            let downloadTask = session.downloadTask(with: fromURL)
            downloadTask.resume()
        }
    }
}

Here's what we did:

  1. Just like caching the file path URL, the continuation associated with the download needs to be cached to be resumed later. The continuation here is a communication path between the caller and callee. When the app is terminated the memory holding the caller and callee will be freed, so unlike with the file path URL storage, the storage of a continuation is only in memory as there is nothing useful to persist. The remote URL will act as the key.
  2. To transform the delegate-based approach into an async/await approach, the download kickoff is wrapped in a withCheckedThrowingContinuation(isolation:function:_:) block.
  3. The metadata associated with this download is stored.
  4. A URLSessionDownloadTask instance is created using the background session, and the download request is kicked off.

To better see the downloads happening in the background, you may wish to add a slight delay before the download starts, making it easier to put the app into the background:

downloadTask.earliestBeginDate = Date().addingTimeInterval(5)

While it is currently possible to add breakpoints to determine what is happening in BackgroundDownloadService as we will see when implementing the app relaunch behaviour, breakpoints will only get us so far. Instead of waiting until then, let's add logging into BackgroundDownloadService just now to log what is happening:

actor BackgroundDownloadService: NSObject {
    // Omitted other properties

    // 1
    private let logger = Logger(subsystem: "com.williamboles",
                                category: "background.download")

    // MARK: - Download

    func download(from fromURL: URL,
                  to toURL: URL) async throws -> URL {
        return try await withCheckedThrowingContinuation { continuation in
            // 2
            logger.info("Scheduling download: \(fromURL.absoluteString)")

            inMemoryStore[fromURL.absoluteString] = continuation
            persistentStore.set(toURL, forKey: fromURL.absoluteString)

            let downloadTask = session.downloadTask(with: fromURL)
            downloadTask.resume()
        }
    }
}

Here's what we did:

  1. Apple recommends using the unified logging system to log events over something like print or NSLog. Logger writes string messages to that unified logging system. Here, we are setting the subsystem and category values, allowing us to filter the logs to see only the messages that interest us.
  2. Logging an event using the info log level.

If you are curious to know more, Antoine van der Lee has written an excellent post on the unified logging system.

Now that BackgroundDownloadService can kick off a download, it needs to conform to URLSessionDownloadDelegate to receive updates about that download. As BackgroundDownloadService is of type actor, conforming to URLSessionDownloadDelegate is slightly more complex than it would be for a class type. By default, an actor enforces mutual exclusion when accessing its state, resulting in those accesses needing to be performed asynchronously from outside the actor. However, URLSession expects synchronous access to its delegate. At first glance, it looks like BackgroundDownloadService can't conform to URLSessionDownloadDelegate; however, what appears impossible at first glance isn't. An actor can have nonisolated methods. A nonisolated method while defined within an actor, is outside its mutual exclusion zone, meaning it can be accessed synchronously - essentially, acting like a regular class method. By marking the URLSessionDownloadDelegate methods as nonisolated, BackgroundDownloadService can act as the delegate to its session - with one caveat: any work that needs to access the state of BackgroundDownloadService needs to be moved into the mutual exclusion zone rather than handled in the nonisolated method.

Let's implement the happy-path method of urlSession(_:downloadTask:didFinishDownloadingTo) from URLSessionDownloadDelegate:

extension BackgroundDownloadService: URLSessionDownloadDelegate { // 1
    // MARK: - URLSessionDownloadDelegate

    // 2
    nonisolated
    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {

        // 3
        let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent)
        try? FileManager.default.moveItem(at: location,
                                          to: tempLocation)

        // 4
        Task {
            await downloadFinished(task: downloadTask,
                                   downloadedTo: tempLocation)
        }
    }
}

Here's what we did:

  1. BackgroundDownloadService conforms to URLSessionDownloadDelegate in an extension.
  2. urlSession(_:downloadTask:didFinishDownloadingTo) is marked as a nonisolated method to allow synchronous access.
  3. When URLSessionDownloadTask completes a download, it will store that downloaded content in a temporary location - location. iOS only guarantees until the end of this method that the downloaded content will be found at location. As the real work of completing a download will occur in downloadFinished(task:downloadedTo:) which is accessed asynchronously via a Task waiting on downloadFinished(task:downloadedTo:) being called before moving the downloaded content will mean that urlSession(_:downloadTask:didFinishDownloadingTo) will have exited and the downloaded content will have been deleted. So before making that asynchronous call, the downloaded content is moved from its current temporary location to a different temporary location that BackgroundDownloadService controls to ensure that it will still be there for downloadFinished(task:downloadedTo:) to work with.
  4. The call to downloadFinished(task:downloadedTo:) is wrapped in a Task as it exists within BackgroundDownloadService mutual exclusion zone, which urlSession(_:downloadTask:didFinishDownloadingTo) is not in, so downloadFinished(task:downloadedTo:) treated as an async method.

Let's see what downloadFinished(task:downloadedTo:) does:

actor BackgroundDownloadService: NSObject {
    // Omitted properties and other methods

    private func downloadFinished(task: URLSessionDownloadTask,
                                  downloadedTo location: URL) {

        // 1
        guard let fromURL = task.originalRequest?.url else {
            logger.error("Unexpected nil URL for download task.")
            return
        }

        logger.info("Download request completed for: \(fromURL.absoluteString)")

        // 2
        defer {
            cleanUpDownload(forURL: fromURL)
        }

        // 3
        guard let toURL = persistentStore.url(forKey: fromURL.absoluteString) else {
            logger.error("Unable to find existing download for: \(fromURL.absoluteString)")
            return
        }

        // 4
        let continuation = inMemoryStore[fromURL.absoluteString]

        logger.info("Download successful for: \(fromURL.absoluteString)")

        // 5
        do {
            try FileManager.default.moveItem(at: location,
                                             to: toURL)
            // 6
            continuation?.resume(returning: toURL)
        } catch {
            logger.error("File system error while moving file: \(error.localizedDescription)")
            // 7
            continuation?.resume(throwing: BackgroundDownloadError.fileSystemError(error))
        }
    }

    // MARK: - Cleanup

    // 8
    private func cleanUpDownload(forURL url: URL) {
        inMemoryStore.removeValue(forKey: url.absoluteString)
        persistentStore.removeObject(forKey: url.absoluteString)
    }
}

This method can be called when the app is foregrounded, suspended, or terminated, so all three states need to be handled in the same method:

  1. In the download(from:to:) method on BackgroundDownloadService, the fromURL was used as the key for storing a download's metadata because that value is present when a download completes in the URLSessionDownloadTask instance. Here, we extract that URL from URLSessionDownloadTask. It's important to note that a URLSessionDownloadTask will automatically follow redirect instructions, so it is essential to ensure that the correct URL is used to connect the completed download with its metadata - specifically, the original URL stored in the originalRequest property. If originalRequest is nil, the method exits as no further action can be taken.
  2. As there are multiple exits from downloadFinished(task:downloadedTo:), a defer block is used to clean up the download by deleting the download's associated metadata on any exit.
  3. If toURL is nil, the download cannot be processed because there is no known location to move the downloaded content to. As a result, the method exits with no further action taken.
  4. Unlike toURL, there is no hard requirement for a continuation to be present, so no check is made to ensure it is not nil.
  5. Using FileManager, the downloaded content is moved from its temporary location to its permanent location.
  6. The associated continuation is resumed with the file's permanent location returned.
  7. The associated continuation is resumed with an error being thrown.
  8. The metadata associated with this download is deleted. This deletion will occur in several methods, so it is best to extract it now.

That's the happy path completed; let's add in the unhappy-path.

The unhappy-path comes in two forms: server-side and client-side errors. Let's start with the server-side errors by updating downloadFinished(task:downloadedTo:):

actor BackgroundDownloadService: NSObject {
    // Omitted properties and other methods

    private func downloadFinished(task: URLSessionDownloadTask,
                                  downloadedTo location: URL) {
        guard let fromURL = task.originalRequest?.url else {
            logger.error("Unexpected nil URL for download task.")
            return
        }

        logger.info("Download request completed for: \(fromURL.absoluteString)")

        defer {
            cleanUpDownload(forURL: fromURL)
        }

        guard let toURL = persistentStore.url(forKey: fromURL.absoluteString) else {
            logger.error("Unable to find existing download for: \(fromURL.absoluteString)")
            return
        }

        let continuation = inMemoryStore[fromURL.absoluteString]

        // 1
        guard let response = task.response as? HTTPURLResponse,
              response.statusCode == 200 else {
            logger.error("Unexpected response for: \(fromURL.absoluteString)")
            continuation?.resume(throwing: BackgroundDownloadError.serverError(task.response))
            return
        }

        // Omitted rest of method
    }
}

Here's what we changed:

  1. To determine if a download was successful, a 200 HTTP status code is expected. Here, the response is checked. If the status code is anything other than a 200, then completionHandler is triggered with serverError.

If your server returns other status codes that should be treated as success, then it is simple enough to add those by refactoring the guard to accommodate those additional status codes.

Now that server-side errors are handled, let's handle the client-side errors. Let's implement the unhappy-path method of urlSession(_:task:didCompleteWithError:) from URLSessionDownloadDelegate:

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    // Omitted other methods

    // 1
    nonisolated
    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        // 2
        Task {
            await downloadComplete(task: task,
                                   withError: error)
        }
    }
}

Here's what we changed:

  1. Just like with urlSession(_:downloadTask:didFinishDownloadingTo), urlSession(_:task:didCompleteWithError:) is marked as a nonisolated method to allow synchronous access.
  2. The call to downloadComplete(task:withError:) is wrapped in a Task as it exists within BackgroundDownloadService mutual exclusion zone, which urlSession(_:task:didCompleteWithError:) is not in, so downloadComplete(task:withError:) is treated as an async method.

Let's see what downloadComplete(task:withError:) does:

actor BackgroundDownloadService: NSObject {
    // Omitted properties and other methods

    private func downloadComplete(task: URLSessionTask,
                                  withError error: Error?) {
        // 1
        guard let error = error else {
            return
        }

        // 2
        guard let fromURL = task.originalRequest?.url else {
            logger.error("Unexpected nil URL for task.")
            return
        }

        logger.info("Download failed for: \(fromURL.absoluteString), error: \(error.localizedDescription)")

        let continuation = inMemoryStore[fromURL.absoluteString]

        // 3
        continuation?.resume(throwing: BackgroundDownloadError.clientError(error))

        // 4
        cleanUpDownload(forURL: fromURL)
    }
}

Here's what we did:

  1. downloadComplete(task:withError:) is called when any network request completes, meaning it will be triggered for both successful and unsuccessful network requests. If a network request fails, error will contain a value. To exclude successful downloads from the unhappy-path, error is checked for nil. If error is nil, the method exits.
  2. Like in downloadFinished(task:downloadedTo:), the remote URL is extracted from the completed URLSessionTask instance as fromURL. If originalRequest is nil, the method exits as no further action can be taken.
  3. The associated continuation is resumed with an error being thrown.
  4. The metadata associated with this download is deleted.

If you've been putting together the code snippets, that's all you need to do to use a background session to download files in the foreground; however, we still have work to do to support relaunching the app to complete a download.

Keeping downloads going

When an app is in a suspended/terminated state and a download completes, iOS will wake the app up (in the background) by calling application(_:handleEventsForBackgroundURLSession:completionHandler:) on the AppDelegate. This method accepts a closure completionHandler that needs to be triggered once we have completed post-download processing for all background downloads. Triggering completionHandler instructs iOS to take a new snapshot of the app's UI for the app switcher preview. Failure to call completionHandler will result in iOS treating the app as a bad citizen, reducing future background processing opportunities. Regardless of how many downloads are in progress, application(_:handleEventsForBackgroundURLSession:completionHandler:) is only called once - when all downloads have been completed. If the user foregrounds the app before the above method is called, urlSession(_:downloadTask:didFinishDownloadingTo:) is called for each completed download.

Depending on how your app is set up, open the section relevant to your project:

SwiftUI

Add the following code to your AppDelegate:

class AppDelegate: NSObject, UIApplicationDelegate {
    // MARK: - UIApplicationDelegate

    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        // 1
        Task {
            await BackgroundDownloadService.shared.saveAppPreviewCompletionHandler(completionHandler)
        }
    }
}

If your app doesn't already have an AppDelegate, you will need to add it - SwiftUI apps don't have one by default.

Here's what we did:

  1. completionHandler is passed to BackgroundDownloadService to be triggered once all post-download work is complete.

Now we need to integrate AppDelegate into the App:

@main
struct BackgroundTransferRevised_ExampleApp: App {
    // 1
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    // Omitted other properties
}

BackgroundTransferRevised_ExampleApp is the name of the example project on which this post is based.

Here's what we did:

  1. Using the @UIApplicationDelegateAdaptor property wrapper to set our custom app delegate.
UIKit

Add the following code to your AppDelegate:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    // MARK: - UIApplicationDelegate

    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        // 1
        Task {
            await BackgroundDownloadService.shared.saveAppPreviewCompletionHandler(completionHandler)
        }
    }
}

Here's what we did:

  1. completionHandler is passed to BackgroundDownloadService to be triggered once all post-download work is complete.

Regardless of how we got here, the completionHandler closure has been passed to BackgroundDownloadService, which has then stored it as a property:

actor BackgroundDownloadService: NSObject {
    // Omitted other properties

    private var appPreviewCompletionHandler: (() -> Void)?

    // Omitted other methods

    func saveAppPreviewCompletionHandler(_ appPreviewCompletionHandler: @escaping (() -> Void)) {
        self.appPreviewCompletionHandler = appPreviewCompletionHandler
    }
}

Just like when the app is in the foreground, the URLSessionDownloadDelegate method: urlSession(_:downloadTask:didFinishDownloadingTo:) is called for each download however, unlike when the app is in the foreground, there is an additional background-transfer only delegate call made after the final urlSession(_:downloadTask:didFinishDownloadingTo:) call:

extension BackgroundDownloadService: URLSessionDownloadDelegate {
    // Omiited other methods

    nonisolated
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        Task {
            await backgroundDownloadsComplete()
        }
    }
}

Here's what we changed:

  1. Just like with the other URLSessionDownloadDelegate methods, urlSessionDidFinishEvents(forBackgroundURLSession:) is marked as nonisolated method to allow synchronous access.
  2. The call to backgroundDownloadsComplete() is wrapped in a Task as it exists within BackgroundDownloadService mutual exclusion zone which urlSessionDidFinishEvents(forBackgroundURLSession:) is not in so backgroundDownloadsComplete() is treated as an async method.

Let's see what backgroundDownloadsComplete() does:

actor BackgroundDownloadService: NSObject {
    // Omitted properties and other methods

    private func backgroundDownloadsComplete() {
        logger.info("All background downloads completed")

        // 1
        appPreviewCompletionHandler?()
        appPreviewCompletionHandler = nil
    }
}

Here's what we did:

  1. appPreviewCompletionHandler is triggered and then set to nil to avoid it accidentally being triggered again.

While it's possible to make additional network requests with the app running in the background, they are subject to a rate limiter to prevent abuse of background sessions - this rate limiter delays the start of those network requests. With each separate network request, the delay increases - this delay is reset when the user brings the app to the foreground or if your app does not make any additional network requests during the delay.

The code has been added to handle the app being relaunched when suspended/terminated, but it is important to verify that the implementation works as intended. When an app is manually force-quit, iOS interprets this as the user explicitly indicating a desire to halt all activity related to the app. Consequently, all scheduled background transfers are cancelled, which prevents testing of the restoration functionality. To overcome this gotcha, app termination needs to happen programmatically using exit(0), which shuts down the app without user intervention.

Depending on how your app is set up, open the section relevant to your project:

SwiftUI
@main
struct BackgroundTransferRevised_ExampleApp: App {
    // Omitted other properties

    @Environment(\.scenePhase) private var scenePhase

    private let logger = Logger(subsystem: "com.williamboles",
                                category: "app")

    // MARK: - Scene

    var body: some Scene {
        WindowGroup {
            // Omitted statements
        }
        .onChange(of: scenePhase) { (_, newPhase) in
            guard newPhase == .background else {
                return
            }

            //Exit app to test restoring app from a terminated state.
            Task {
                logger.info("Simulating app termination by exit(0)")

                exit(0)
            }
        }
    }
}

Remember to remove this in production 😜.

UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    private let logger = Logger(subsystem: "com.williamboles",
                                category: "appDelegate")

    // Omitted other methods

    func applicationDidEnterBackground(_ application: UIApplication) {
        //Exit app to test restoring app from a terminated state.
        Task {
            logger.info("Simulating app termination by exit(0)")

            exit(0)
        }
    }
}

Remember to remove this in production 😜.


With the above changes, the app will be terminated when it goes into the background. However, once the app is terminated, the debugger will lose its link with the app, meaning any set breakpoints will be made invalid 😟. But don't be too sad, as we've built up this solution, we've been using Logger to output what the app has been doing. These logs will keep being produced even when the debugger isn't linked. These logs can be seen in the Console app. It is possible to filter the logs in the Console app - as logged events have been using com.williamboles as the subsystem value, this can be used to filter:

Screenshot of Console app showing filtered events

Running the app with the Console open should allow us to see that any in-progress downloads complete beyond the app termination.

So, we've implemented the ability to start downloading when the app is in the foreground and have that continue even if the app is terminated. However, there is one more common scenario to implement - handling duplicate requests. At the moment, if a duplicate download is made before the first one has completed, then the following error is thrown:

SWIFT TASK CONTINUATION MISUSE: download(from:to:) leaked its continuation!

This error is due to the metadata associated with the first download being replaced without the continuation being resumed.

There are two approaches we could implement to handle this scenario:

  1. Coalescing - piggybacking any subsequent request on the in-progress request and so calling multiple continuations when the download completes.
  2. Cancellation - cancelling the in-progress request, throwing an error and starting a new request.

Both of these approaches are valid, and which one you go for depends on the requirements of your app. Cancellation is more straightforward, so let's implement it.

To cancel a download, we need access to its URLSessionDownloadTask instance, so let's track the currently active downloads:

actor BackgroundDownloadService: NSObject {
    // Omitted other properties

    // 1
    private var activeDownloads = [String: URLSessionDownloadTask]()

    // Omitted other methods

    func download(from fromURL: URL,
                  to toURL: URL) async throws -> URL {
        return try await withCheckedThrowingContinuation { continuation in
            // Omitted other statements

            let downloadTask = session.downloadTask(with: fromURL)
            // 2
            activeDownloads[fromURL.absoluteString] = downloadTask
            downloadTask.resume()
        }
    }

    private func cleanUpDownload(forURL url: URL) {
        //Omitted other statements

        // 3
        activeDownloads.removeValue(forKey: url.absoluteString)
    }
}

Here's what we did:

  1. Similar to the continuation caching, the URLSessionDownloadTask instance is in a dictionary. The remote URL will act as the key.
  2. The URLSessionDownloadTask instance is stored.
  3. When a download is completed, the URLSessionDownloadTask instance is removed from activeDownloads.

Now that we have access to the URLSessionDownloadTask instance associated with a download, we can cancel it:

actor BackgroundDownloadService: NSObject {
    // Omitted properties and other methods

    func cancelDownload(forURL url: URL) {
        logger.info("Cancelling download for: \(url.absoluteString)")

        // 1
        inMemoryStore[url.absoluteString]?.resume(throwing: BackgroundDownloadError.cancelled)

        // 2
        activeDownloads[url.absoluteString]?.cancel()

        // 3
        cleanUpDownload(forURL: url)
    }
}

Here's what we did:

  1. The continuation associated with url is resumed with a cancelled error being thrown.
  2. The URLSessionDownloadTask instance associated with url is cancelled.
  3. The metadata associated with url is deleted.

The cancellation shown is a simple throw-everything-away cancellation; if you want a more sophisticated approach, check out my post on pausable downloads to see how a download can be resumed from where it was cancelled.

When a download cancels the downloadComplete(task:withError:) method is called with a URLError.cancelled error, let's update that method so we don't double clean up cancelled downloads:

actor BackgroundDownloadService: NSObject {
   // Omitted properties and other methods

    private func downloadComplete(task: URLSessionTask,
                                  withError error: Error?) {
        guard let error = error else {
            return
        }

        // 1
        if let error = error as? URLError,
           error.code == .cancelled {
            return
        }

        // Omitted rest of method
    }
}

Here's what we did:

  1. error is to see if it is an URLError.cancelled error, if so the method is exited.

All that is left to do is to cancel the active download when a new download request is made:

actor BackgroundDownloadService: NSObject {
    // Omitted properties and other methods

    func download(from fromURL: URL,
                  to toURL: URL) async throws -> URL {
        // 1
        if activeDownloads[fromURL.absoluteString] != nil {
            cancelDownload(forURL: fromURL)
        }

        // Omitted rest of method
    }
}

Here's what we did:

  1. activeDownloads is checked for an entry with fromURL as the key. If that entry exists, the download is cancelled; if that entry does not exist, cancellation is skipped.

With those changes, no continuation misuse error will be thrown when a duplicate download request is made.

Congratulations, that's all the code needed to support background-transfers.

πŸ’ƒ πŸ•Ί

Downloads keep going 🀯

Background-transfers provide a powerful tool to meet your users' ever-increasing expectations about what your app can do for them, even when it isn't in the foreground. The enhanced user experience we gain from supporting background-transfers, I believe, outweighs any additional complexity added by supporting background-transfers.

To see the complete working example, visit the repo and clone the project.


Running the example project

I've used TheCatAPI in the example project to populate the app with downloaded images. TheCatAPI has an extensive library of freely available cat photos which it shares via a JSON-based API. TheCatAPI does require you to register to get full access to it's API (limited access is provided without an API key). Once registered you will be given an x-api-key token which you can paste as the APIKey value in NetworkService to get that full access.