iosdev

Background (multipart) file upload from iOS app

I recently had to tackle uploading somewhat larger files from an iOS app to a backend service. It’s not a type of functionality that’s often needed thus every time I have to do it I end up re-discovering some gotchas. Best way to remember is to write it down hence this blog post.

Apple has great API for this purpose: URLSessionUploadTask. Almost at the very top, Apple says:

Unlike data tasks, you can use upload tasks to upload content in the background.

This is not by accident. File uploads are the least-loved feature of any app as no one wants to wait nor look at progress bars. Thus the proper way to do this is to encapsulate upload functionality into a module which is somewhat independent from the rest of the app and maintain an ability to be informed how far along the transfer is, did it finish or encountered an error etc. That module should do its job in background, without ever bothering the user.

File Uploader module

As example, here’s a barebone module called FileUploader which can be extended to support any number of functionality your app needs.

final class FileUploader: NSObject {
	typealias Percentage = Float
	
	typealias ProgressHandler = @Sendable (Percentage) -> Void
	
	///	Gives nothing if success or encountered error.
	typealias CompletionHandler = @Sendable (Result<Void, Error>) -> Void

This is the least we need to make this usable. You need to keep an uploader reference somewhere in the app and that’s all we need as start.

Next — this module uses local instance of URLSession which is configured for background-enabled transfers:

private let backgroundIdentifier = "\( Bundle.identifier ).background"

private var urlSessionConfiguration: URLSessionConfiguration {
	let config = URLSessionConfiguration.background(withIdentifier: backgroundIdentifier)
	return config
}

.background(withIdentifier:) is important because it offers a great piece of functionality:

If an iOS app is terminated by the system and relaunched, the app can use the same identifier to create a new configuration object and session and to retrieve the status of transfers that were in progress at the time of termination.

Thus even if background transfers are slow and app is terminated by the system before they finish, you have the ability to pickup the pieces and continue on. That of course requires equally capable backend but the possibility is there.

Now, we can create a local instance, using the delegate variant of the API:

// Creating our custom URLSession instance. We'll do it lazily
// to enable 'self' to be passed as the session's delegate:
private lazy var urlSession = URLSession(
	configuration: urlSessionConfiguration,
	delegate: self,
	delegateQueue: .main
)

We need the delegate form to receive progress reports and we enforce the delivery of those reports on the OperationQueue.main since the most likely purpose is to update some piece of UI.

The minimal set of steps to upload the file is:

  1. Prepare the file at some local URL.
  2. Prepare the URLRequest with all the proper headers to perform the upload.
  3. Start the transfer task, making sure it’s not blocking any UI or other work in the app.
  4. Wait for progress / result delegate callbacks to update tracking UI.
  5. Cleanup temporary stuff.

I’ll tackle steps 1/2 later in the article; first we need to prepare our module. Input point into the uploader will be a method that receives file URL and URLRequest + progress and result handlers:

func uploadFile(
	at fileURL: URL,
	to urlRequest: URLRequest,
	progressHandler: @escaping ProgressHandler,
	completionHandler: @escaping CompletionHandler
){
	...
}

FileUploader needs to keep track of upload-task’s .taskIdentifier versus supplied ProgressHandler and CompletionHandler, which is easy to do:

private var completionHandlersByTaskID = [Int : CompletionHandler]()
private var progressHandlersByTaskID = [Int : ProgressHandler]()

The body of the method is very straightforward:

let task = urlSession.uploadTask(with: urlRequest, fromFile: fileURL)

progressHandlersByTaskID[task.taskIdentifier] = progressHandler
completionHandlersByTaskID[task.taskIdentifier] = completionHandler

task.resume()

Tracking the uploads

We need to handle two delegate callbacks:

extension FileUploader: URLSessionTaskDelegate {
}

First — progress report:

func urlSession(
	_ session: URLSession,
	task: URLSessionTask,
	didSendBodyData bytesSent: Int64,
	totalBytesSent: Int64,
	totalBytesExpectedToSend: Int64
) {
	guard
		let handler = progressHandlersByTaskID[task.taskIdentifier],
	else {
		return
	}
	
	let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
	handler(Float(progress))
}

Second is result (completion) handler:

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError err: (any Error)?) {
	guard
		let completionHandler = completionHandlersByTaskID[task.taskIdentifier],
	else {
		return
	}
	
	if let err {
		completionHandler(.failure(NetworkError.generalError(err)))
		return
	}
	
	completionHandler(.success(()))
}

Note that this is bare minimum. There are quite a few other delegate methods which are used in more advanced or edge-case scenarios.

Handling all possible cases and scenarios is far from easy; if you get into trouble I highly recommend Apple Developer Forums and especially threads [1], [2] and [3] where Quinn “The Eskimo!” responded with elaborate details.

Preparing the URLRequest

I have set this as input parameter because it’s quite often unrelated to the upload itself. You might be uploading to a password-protected or OAuth2-access-token backend. Thus you will need to handle all of that before you attempt the upload.

The only upload-related consideration: is your backend accepting

In the first case you just need to supply proper MIME type for Content-Type HTTP header and you’re done. As example, if you are uploading a JPEG photo, it would be this:

headers["Content-Type"] = "image/jpeg"

For the latter — you need to specify what’s your boundary which is simple UUID string. More on this in the next segment.

headers["Content-Type"] = "multipart/form-data; boundary=\( boundary )"

Preparing a local file is highly dependent on which of these two options your backend expects.

Preparing local file as upload source

Background file upload only works with local file instances. You can’t use Data nor InputStream — it must be a file on the disk.

Second important point is that the source file can’t be protected in any way. If you attempt to background upload a file with URLFileProtection other than .none you’ll be greeted with API MISUSE warning in the Xcode console.

Thus in the case of raw binary upload, I recommend to alter the file protection of the local file. Or copy the original file into some temporary location and remove file protection from that copy. Whatever the case, you should be using setAttributes(_:ofItemAtPath:) API:

let fm = FileManager.default

let localFilePath = realFileURL.path()

try fm.setAttributes(
	[.protectionKey: FileProtectionType.none],
	ofItemAtPath: localFilePath
)

Upload imitating web-forms is more involved.
The HTTP body of the URLRequest in this case is part-string, part-binary where using Data would be my first choice. But as I already said, Data as upload source is not an option. So we need to fake it: write-out upload contents to a temporary file and use that as UploadTask’ source.

let boundary = UUID().uuidString
// prepare URLRequest here

// now prepare file source
let realFileURL: URL // real file we want to upload
let localURL: URL // temporary fileURL
do {
	guard let outputStream = OutputStream(url: localURL, append: false) else {
		throw OutputStream.OutputStreamError.unableToCreateFile(localURL)
	}
	
	outputStream.open()
	try outputStream.write("--\( boundary )\r\n")
	try outputStream.write("Content-Disposition: form-data; name=\"file\"; filename=\"\( realFileURL.lastPathComponent )\"\r\n")
	try outputStream.write("Content-Type: image/jpeg\r\n\r\n")
	try outputStream.write(contentsOf: realFileURL)
	try outputStream.write("\r\n")
	try outputStream.write("--\( boundary )--\r\n")
	outputStream.close()
	
	//	now changefile protection to none
	try fm.setAttributes(
		[.protectionKey: FileProtectionType.none], 
		ofItemAtPath: localFilePath
	)

} catch {...}

Note that boundary used here must be the same one you used to prepare the Content-Type header for the URLRequest instance.

In the end, when file upload is done, don’t forget to cleanup all these temporary files.


I skipped over many things as creating a robust file uploader is a lot of work accompanied by a lot of testing. But for occasional uploads in somewhat controlled environment — your app to your backend — this is likely almost everything that you’ll need.