Home / Blog / Background URLSession: making 2 GB uploads actually reliable

Background URLSession: making 2 GB uploads actually reliable

One of my apps lets users upload 1 to 2 GB video files. The upload has to keep going when WiFi drops, when the app closes, when the device locks. Foreground URLSession wasn’t enough. Moving to a background URLSession came with subtleties I didn’t see coming. Setup: watch the identifier private static let sessionIdentifier = […]

One of my apps lets users upload 1 to 2 GB video files. The upload has to keep going when WiFi drops, when the app closes, when the device locks. Foreground URLSession wasn’t enough. Moving to a background URLSession came with subtleties I didn’t see coming.

Setup: watch the identifier

private static let sessionIdentifier = "com.mycompany.app.upload"

private lazy var uploadSession: URLSession = {
    let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
    config.isDiscretionary = false
    config.sessionSendsLaunchEvents = true
    config.allowsCellularAccess = true
    return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

Important: never create more than one URLSession instance with the same identifier. iOS tracks these internally and a second creation can crash. Use a singleton.

Upload from file, not from Data

A background session supports uploading a file on disk, not a Data blob in memory:

let task = uploadSession.uploadTask(with: request, fromFile: tempURL)
task.resume()

The uploadTask(with:from:) data variant does not work in the background. On my first version I hit this exact bug: the task never even starts, and no error is thrown. You only catch it by reading Apple’s release notes carefully.

Delegate: wiring up AppDelegate

When an upload finishes while the app is closed, iOS relaunches the app in the background. You add a method to AppDelegate to handle it:

func application(
    _ application: UIApplication,
    handleEventsForBackgroundURLSession identifier: String,
    completionHandler: @escaping () -> Void
) {
    BackgroundUploader.shared.pendingCompletion = completionHandler
    // iOS will drive the session delegate from here
}

Then in the session’s urlSessionDidFinishEvents(forBackgroundURLSession:) delegate method, call completionHandler():

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    DispatchQueue.main.async {
        self.pendingCompletion?()
        self.pendingCompletion = nil
    }
}

Skip this and iOS terminates the app quickly, which cuts off any follow-up callbacks.

Progress and resume

When WiFi drops at 40% and comes back, iOS does not auto-resume. You have to inspect pending tasks in the session and reschedule:

uploadSession.getAllTasks { tasks in
    for task in tasks {
        if task.state == .suspended || task.state == .canceling {
            task.resume()
        }
    }
}

I run this check every time the app comes to the foreground. One warning though: the background URLSession retry behaviour is weak. If the server returns a 500, the task fails and never retries. Write your own retry logic.

Server-side resume: range requests

For large files, Range request support on the server is mandatory. The backend I use (custom, with S3 behind it) accepts chunked uploads via a Content-Range: bytes 0-104857599/1073741824 header. On the client I split the upload into chunks:

// 50 MB chunks
let chunkSize: Int64 = 50 * 1024 * 1024
let totalSize = fileSize
var offset: Int64 = 0
while offset < totalSize {
    let end = min(offset + chunkSize - 1, totalSize - 1)
    let chunkData = try readRange(url: fileURL, offset: offset, length: end - offset + 1)
    let tempChunk = writeChunkToTemp(chunkData)
    var req = URLRequest(url: uploadURL)
    req.httpMethod = "PUT"
    req.setValue("bytes (offset)-(end)/(totalSize)", forHTTPHeaderField: "Content-Range")
    let task = uploadSession.uploadTask(with: req, fromFile: tempChunk)
    task.taskDescription = "(offset)"
    task.resume()
    offset = end + 1
}

Each chunk is queued as its own background task. If one fails, I only retry that chunk.

Battery and data policy

With isDiscretionary = true, iOS holds the upload until the device is on WiFi, charging, and idle. If you told the user "upload started" and they're complaining six hours later that it's still uploading, this setting is probably why. For critical flows I use isDiscretionary = false, but Apple prefers you leave it on for optional content.

Memory: the app stays suspended in the background

The background URLSession runs in its own process, so it keeps going even when the app is suspended. But the delegate methods only fire when the app is relaunched. An upload that started ten minutes ago and finished nine minutes later only fires its completion event when you reopen the app. Persistence matters even across crashes.

Debugging: console logs only get you so far

Debugging a background URLSession in Xcode is not fun. I write upload progress to a JSON file under Application Support, and on next launch I read it back and reflect it in the UI. Dropping breadcrumbs via os_log to the console helps too.

Closing advice

Background URLSession is not an API you test for 30 minutes and declare "works". You need at least a week of real daily use from 15 to 20 users. It fails in situations you don't expect, and finishes on its own in situations you don't expect. But once it's set up right, giving users reliable large-file uploads is a very powerful feature.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch