URLSession: Common pitfalls with background download & upload tasks

URLSession enables you to download and upload files while the app is in the background. Basic instructions to get it working are often found online, but it’s hard to make it work as expected and debug the flows. After implementing background uploading support for Collect by WeTransfer myself, I decided to write down my learnings. Both to help you and my future self ;-).

For my day-to-day job, I’m developing new features for the Collect app. One of these features is background downloading and uploading, in which an app extension can also trigger both. Implementing background data transfer support turned out to come with quite a few pitfalls.

Note: In this blog post I’m going over some common pitfalls. If you’re looking for ways to set up background downloading and uploading, take a look at this documentation by Apple to kickstart your implementation. After that, of course, head back and read up on my learnings and save yourself from common pitfalls.

1. Each URLSessionConfiguration needs a unique identifier

Not using a unique URLSessionConfiguration identifier is mainly an issue if you’re triggering background downloads or uploads from your main app and an app extension. When your host app is relaunched with an identifier passed in the following method:

func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void)

You could ignore the passed identifier and rebuild the URLSession as you would for your host app. However, with app extensions, you’re dealing with multiple URLSession instances. Each of those needs its own custom identifier, and that identifier needs to be used to rebuild your session and resume running tasks correctly.

I came to this conclusion after reading this documentation by Apple, stating:

Because only one process can use a background session at a time, you need to create a different background session for the containing app and each of its app extensions. (Each background session should have a unique identifier.) It’s recommended that your containing app only use a background session that was created by one of its extensions when the app is launched in the background to handle events for that extension. If you need to perform other network-related tasks in your containing app, create different URL sessions for them.

For us, this basically meant to create a new URLSession for any incoming identifier and cache that instance:

/// Contains any `URLSession` instances associated with app extensions.
private lazy var appExtensionSessions: [URLSession] = []

/// Creates an identical `URLSession` for the given identifier or returns an existing `URLSession` if it was already registered.
///
/// - Parameter identifier: The `URLSessionConfiguration` identifier to use for recreating the `URLSession`.
/// - Returns: A newly created or existing `URLSession` instance matching the given identifier.
private func session(for identifier: String) -> URLSession {
    if let existingSession = appExtensionSessions.first(where: { $0.configuration.identifier == identifier }) {
        return existingSession
    }

    let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
    configuration.sharedContainerIdentifier = appGroup
    let appExtensionSession = URLSession(configuration: configuration, delegate: self, delegateQueue: sessionOperationQueue)
    appExtensionSessions.append(appExtensionSession)
    return appExtensionSession
}

Bundle based identifiers

To play it safe, you can use the following code to make sure your URLSessionConfiguration identifier is always unique.

let appBundleName = Bundle.main.bundleURL.lastPathComponent.lowercased().replacingOccurrences(of: " ", with: ".")
let sessionIdentifier: String = "com.networking.\(appBundleName)"
let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)

2. Don’t forget the shared container identifier

If you’re implementing background downloading and uploading inside an app extension, you need to set the shared container identifier. As app extensions get terminated quickly, you need to make sure you’re performing those data tasks on a background URLSession. You can refer to Sharing Data with Your Containing App for guidance on setting up a shared container. The same link contains info on handling common scenarios in App Extensions and also covers downloading and uploading.

You can set the identifier as followed:

let configuration = URLSessionConfiguration.background(withIdentifier: "swiftlee.background.url.session")
configuration.sharedContainerIdentifier = "group.swiftlee.apps"

3. Only URLSession upload tasks from a file support background execution

Upload tasks only continue in the background if they’re uploading from a file reference. Save the file you want to upload locally first and start uploading from that file location. Uploads from data instances or a stream fail directly after the app exits. So keep an eye sharp here, as it might seem to work at first. However, we’re uploading in the background, and that has to work!

4. The isDiscretionary parameter can really hurt

I remember myself sitting in front of my desk, staring at my screen. Downloads didn’t work until I plugged in the phone charger. After reading the documentation for almost every method and parameter, I encountered the isDiscretionary property:

For configuration objects created using the background(withIdentifier:) method, use this property to give the system control over when transfers should occur.

That already made me think. Reading on:

When transferring large amounts of data, you are encouraged to set the value of this property to true. Doing so lets the system schedule those transfers at times that are more optimal for the device. For example, the system might delay transferring large files until the device is plugged in and connected to the network via Wi-Fi.

And it totally made sense!

To be clear, it’s good to set this property to true in some cases. However, for our use case with the Collect app, we needed instant downloading and uploading. Therefore, we had to set the property back to its default value of false. It’s also important to point out that this property will be ignored if you’re creating non-background URLSession instances.

5. Attaching the Xcode debugger prevents your app from suspending

Background uploading starts when your app is getting suspended by the system. AppDelegate methods like handleEventsForBackgroundURLSession will only be called after your app was suspended, so if you want to test this flow, you need to be sure your app gets suspended.

It might make sense to keep your Xcode debugger attached, move your app in the background, waiting for the upload to continue. However, the Xcode debugger will prevent your app from getting suspended, and you’ll end up never seeing your code get executed.

If you really need to debug the continuation flow after an app suspends, you can make use of the “Wait for the executable to be launched” option from the scheme settings:

Waiting for the executable to be launched can be a way to test your continuation flow after a URLSession background task succeeds.
Waiting for the executable to be launched can be a way to test your continuation flow after a URLSession background task succeeds.

If you like, you can always detach and then reattach later on. Just make sure to detach and allow your app to suspend.

Note that your app will only continue after all running tasks succeeded. More about that later!

6. Use OSLog to read your logs while a background upload is running

As a good replacement of the Xcode debugger, you can make use of OSLog. I’ve explained how you can set this up in detail in my article OSLog and Unified logging as recommended by Apple.

While keeping the Console app open, you can start running URLSession upload tasks and move your app into the background. The more detailed your logging implementation is, the easier it will debug flaws in your uploading logic.

7. Use a proxy to verify outgoing URLSession requests

A proxy allows seeing all requests that are triggered from your app’s URLSession. I’m a big fan of using Charles Proxy, but there are many great alternatives.

It might not be important to look at the details of the outgoing request, but it’s super-valuable to see at least that an upload is still running and therefore preventing your suspended app from resuming. This gets me to the next point:

8. Be patient, it can take time before your app is getting relaunched for a URLSession

After your app is suspended, it can take time before the system resumes your app with the handleEventsForBackgroundURLSession AppDelegate method and the related URLSession. I’ve had days in which I debugged for hours, went into a meeting, came back, and realized that the upload continued after 10 minutes!

This delay in resuming your app likely enlarges when requesting more and more uploads. Therefore, it might be time-consuming to test your flows. Lastly, your app will only be relaunched after all running background tasks succeed.

9. Test on a real device

Simulators have been improved over the years, but support for background uploads hasn’t been great so far. Therefore, I always prefer to test on a real device that will always be closer to our users’ environment.

It’s good to know either way that if your flow is failing, it’s not because of the iOS Simulator.

10. Speed up your testing flow

How better to end this list with a few tips on speeding up your testing flow for background tasks.

Starting from scratch

First of all, it’s good to start with a clean slate. We can do this by executing the following piece of code on launch to invalidate our URLSession and its tasks:

#if DEBUG
    if debuggingBackgroundTasks {
        /// Cancel any running tasks and invalidate the session.
        URLSession.shared.invalidateAndCancel()
    }
#endif

We use the #if DEBUG check to make sure this code never ends up in our production builds. Using a custom property to enable background task debugging, we only use this invalidation method when we actually need it.

Make your app suspend sooner

Waiting for your app to suspend is a waste of time. By using exit(0) we can force our app to suspend:

func applicationDidEnterBackground(_ application: UIApplication) {
    #if DEBUG
        if debuggingBackgroundTasks {
            /// Exit to make our app suspend directly.
            exit(0)
        }
    #endif
}

This will terminate your app, but the system does not interpret this as a force quit. Therefore, your app will relaunch your app in the background when your background session needs attention.

Conclusion

Testing background uploads and downloads can be time-consuming, especially if you’re not familiar with certain debug limitations. Hopefully, after reading this article, it’s easier for you to test all your flows. By speeding up your testing workflow, you’ll save even more time in the end.

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!