How To Upload Files Using Combine in iOS & Track Their Progress

Have you ever wondered how to upload files using Combine in iOS? Or maybe how to track their progress? After doing some research, I wasn’t able to find anything relevant to this topic, so I had to put in the time myself. I’d now like to share my findings with you.

Let’s start from scratch and build our solution block by block. Apple did a good job of adding Combine publishers everywhere across their system frameworks. Unfortunately, URLSession only provides

func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher

which can be used for regular requests but is not sufficient for file uploads. Instead, we will use a standard API and wrap it into a publisher to make it compatible with Combine pipelines.

func uploadTask(
	with request: URLRequest, 
    fromFile fileURL: URL, 
    completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionUploadTask

Please note that you can also go for another method that uses Data as opposed to URL; however, this approach is not recommended since the whole file will be kept in memory, increasing the application footprint. This can have a negative impact on performance, especially in case of larger files like videos. We will create a dreaded APIManager to demonstrate the solution — but feel free to experiment!

class APIManager: NSObject {
	func upload(request: URLRequest, fileURL: URL) -> ?
}

What is the return type, you might ask? Well, there are three things we need to track: Successful upload, its progress and errors.

One option will be to go for something like (AnyPublisher<Data?, Error>, AnyPublisher<Double, Never>). Despite allowing some flexibility with the individual publishers, I prefer something more elegant — AnyPublisher<UploadResponse, Error> — where the response slightly resembles a Swift result type.

enum UploadResponse {
	case progress(percentage: Double)
	case response(data: Data?)
}

Now we are able to tackle the uploading part.

class APIManager: NSObject {

	func upload(
    	request: URLRequest, 
        fileURL: URL
    ) -> AnyPublisher<UploadResponse, Error> {
		let subject: PassthroughSubject<UploadResponse, Error> = .init()
		let task: URLSessionUploadTask = URLSession.shared.uploadTask(
			with: request,
			fromFile: fileURL
		) { data, response, error in
			if let error = error {
				subject.send(completion: .failure(error))
				return
			}
			if (response as? HTTPURLResponse)?.statusCode == 200 { 	
            	subject.send(.response(data: data))
				return
			}
			subject.send(.response(data: nil))
		}
		task.resume()
		return subject.eraseToAnyPublisher()
	}
}

Basically, we will create a PassthroughSubject or a Future that will emit events once a completion closure is triggered. Of course, response and error handling may vary based on the actual API. Finally, we should take care of progress updates during our upload. Apple provides URLSessionTaskDelegate to observe those changes.

func urlSession(
	_ session: URLSession, 
    task: URLSessionTask, 
    didSendBodyData bytesSent: Int64, 
    totalBytesSent: Int64, totalBytesExpectedToSend: Int64
)

Let's create a custom URLSession and a publisher in order to track the progress.

let progress: PassthroughSubject<(id: Int, progress: Double), Never> = .init()
lazy var session: URLSession = {
	.init(configuration: .default, delegate: self, delegateQueue: nil)
}()

We will also wire it up to the delegate, like this.

func urlSession(
	_ session: URLSession, 
    task: URLSessionTask, 
    didSendBodyData bytesSent: Int64, 
    totalBytesSent: Int64, 
    totalBytesExpectedToSend: Int64
) {
	progress.send((
    	id: task.taskIdentifier, 
        progress: task.progress.fractionCompleted
    ))
}

The only thing still missing is to connect both flows together. Gladly, Combine allows us to do it almost effortlessly.

return progress
// Otherwise, we will receive events from all the current tasks
.filter{ $0.id == task.taskIdentifier } 
.setFailureType(to: Error.self) // Just to make the compiler happy :)
.map { .progress(percentage: $0.progress) }
.merge(with: subject)
.eraseToAnyPublisher()

Here is the final solution handling file uploads and tracking their progress at the same time.

class APIManager: NSObject {
	let progress: PassthroughSubject<(id: Int, progress: Double), Never> = .init()
	lazy var session: URLSession = {
		.init(configuration: .default, delegate: self, delegateQueue: nil)
	}()

	func upload(
    	request: URLRequest, 
        fileURL: URL
    ) -> AnyPublisher<UploadResponse, Error> {
		let subject: PassthroughSubject<UploadResponse, Error> = .init()
		let task: URLSessionUploadTask = session.uploadTask(
			with: request,
			fromFile: fileURL
		) { data, response, error in
			if let error = error {
				subject.send(completion: .failure(error))
				return
			}
			if (response as? HTTPURLResponse)?.statusCode == 200 {
				subject.send(.response(data: data))
				return
			}
			subject.send(.response(data: nil))
		}
		task.resume()
		return progress
			.filter{ $0.id == task.taskIdentifier }
			.setFailureType(to: Error.self)
			.map { .progress(percentage: $0.progress) }
			.merge(with: subject)
			.eraseToAnyPublisher()
	}
}

extension APIManager: URLSessionTaskDelegate {
	func urlSession(
    	_ session: URLSession, 
        task: URLSessionTask, 
        didSendBodyData bytesSent: Int64, 
        totalBytesSent: Int64, 
        totalBytesExpectedToSend: Int64
     ) {
		progress.send((
        	id: task.taskIdentifier, 
            progress: task.progress.fractionCompleted
        ))
     }
}

Now you are able to integrate this uploading publisher into any of the Combine flows. Thank you for sticking with me until the end!

apiManager.upload(request: request, fileURL: url)
	.receive(on: OperationQueue.main)
	.sink(receiveCompletion: { completion in
		if case let .failure(error) = completion {
			// Handle error
		}
	}) { uploadResponse in
		switch uploadResponse {
		case let .progress(percentage):
		// Handle progress
		case let .response(data):
		// Handle response
		}
}

P.S. Don't forget to use .receive(on: OperationQueue.main) after subscribing to this publisher, since changing progress will most likely result in UI updates. :)

Share Article
Gleb Arkhipov

Gleb Arkhipov

iOS Engineer

You might also like...