Building a Networking SDK — Part 8 — DownloadTask progress tracking

When you think about a networking layer, what first comes to mind? Probably making CRUD requests to a REST API, receiving JSON responses, decoding data, and updating your UI. But networking is much more than just basic CRUD operations — there are many other essential features, including one we’ll focus on today: downloading files.

In previous parts of my EZNetworking SDK series, I introduced the FileDownloader – a simple utility that makes file downloads easy to incorporate into your iOS apps. You can read more about the basic FileDownloader implementation in Part 2 of this series.

While reviewing my code recently, I noticed an opportunity for improvement. In almost every app that handles file downloads, there’s one feature that significantly enhances the user experience: tracking download progress.

Overview:

  • Why tracking download progress is essential
  • How to use DownloadTaskInterceptor to track download progress
  • Improving FileDownloader to simplify progress tracking implementation

Why Is Tracking Download Progress Essential?

When it comes to downloading files in an app, progress tracking isn’t just a “nice-to-have” — it’s a core component of creating a polished and responsive user experience. Imagine downloading a large PDF, video, or app content without any indication of how long it will take or whether anything is happening at all. As a user, that’s frustrating and creates uncertainty.

Progress tracking solves this problem by:

  • Providing real-time feedback that reassures users the app is functioning correctly
  • Reducing perceived wait time by showing active progress
  • Building trust in your app’s performance
  • Giving users a sense of control over the download process

From a UX perspective, showing a progress bar or percentage complete makes the download experience feel responsive and professional. From a development standpoint, exposing download progress enables you to integrate custom logic, such as:

  • Enabling or disabling UI elements based on download state
  • Showing estimated time remaining
  • Allowing users to cancel long downloads
  • Updating UI elements like progress bars or animations

Ultimately, tracking progress transforms what would otherwise be an opaque network operation into a transparent, user-first experience. It’s particularly important in apps that deal with large media files, implement offline-first workflows, or operate in environments with unreliable connectivity.

How to Use DownloadTaskInterceptor to Track Download Progress

Tracking download progress is already possible in EZNetworking. In Part 7 of this series, I introduced the SessionDelegate, which allows clients to intercept network requests.

SessionDelegate conforms to URLSessionDownloadDelegate, and by injecting a custom DownloadTaskInterceptor, you can intercept URLSessionDownloadTask events, including download progress updates.

Here’s how download progress tracking can already be implemented:

// Step 1: Create a custom DownloadTaskInterceptor
struct CustomDownloadTaskInterceptor: DownloadTaskInterceptor {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// Handle download completion
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
// Track download progress
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
print("Download progress: \(progress * 100)%")
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
// Handle download resumption
}
}
// Step 2: Create a SessionDelegate and inject your interceptor
let sessionDelegate = SessionDelegate()
sessionDelegate.downloadTaskInterceptor = CustomDownloadTaskInterceptor()
// Step 3: Inject your SessionDelegate into FileDownloader
let downloader = FileDownloader(sessionDelegate: sessionDelegate)
// Step 4: Begin the download
let downloadTask = downloader.downloadFileTask(url: fileURL) { result in
// Handle download result
}

This approach works well, but it can be further simplified to provide an even better developer experience.

Improving FileDownloader for Easier Progress Tracking

While the current implementation is functional, I wanted to make progress tracking even easier to implement. My solution was to enhance the DownloadTaskInterceptor to include a closure-based approach for progress tracking.

Step 1: Update the DownloadTaskInterceptor Protocol

First, I updated the DownloadTaskInterceptor protocol by adding a progress closure property:

public protocol DownloadTaskInterceptor: AnyObject {
/// Track the progress of the download process
var progress: (Double) -> Void { get set }
// Existing methods remain...
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL)
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64)
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64)
}

Step 2: Create a Default Internal DownloadTaskInterceptor

Next, I implemented a default interceptor that handles progress tracking internally:

/// Default implementation of DownloadTaskInterceptor
private class DefaultDownloadTaskInterceptor: DownloadTaskInterceptor {
var progress: (Double) -> Void
init(progress: @escaping (Double) -> Void = { _ in }) {
self.progress = progress
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
progress(1.0) // Complete
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let currentProgress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
progress(currentProgress)
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
let currentProgress = Double(fileOffset) / Double(expectedTotalBytes)
progress(currentProgress)
}
}

Step 3: Update the FileDownloadable Protocol

I expanded the FileDownloadable protocol with progress-tracking versions of the existing methods:

public protocol FileDownloadable {
// Existing methods
func downloadFile(with url: URL) async throws -> URL
@discardableResult
func downloadFileTask(url: URL, completion: @escaping((Result<URL, NetworkingError>) -> Void)) -> URLSessionDownloadTask
// New methods with progress tracking
func downloadFile(with url: URL, progress: DownloadProgressHandler?) async throws -> URL
@discardableResult
func downloadFileTask(url: URL, progress: DownloadProgressHandler?, completion: @escaping((Result<URL, NetworkingError>) -> Void)) -> URLSessionDownloadTask
}
public typealias DownloadProgressHandler = (Double) -> Void

Step 4: Implement the Updated FileDownloader

Finally, I implemented the improved version of FileDownloader with built-in progress tracking:

public class FileDownloader: FileDownloadable {
private let urlSession: URLSessionTaskProtocol
private let validator: ResponseValidator
private let requestDecoder: RequestDecodable
private var sessionDelegate: SessionDelegate
private let fallbackDownloadTaskInterceptor: DownloadTaskInterceptor = DefaultDownloadTaskInterceptor()
// Initialization methods remain unchanged...
public func downloadFile(with url: URL) async throws -> URL {
try await downloadFile(with: url, progress: nil)
}
public func downloadFile(with url: URL, progress: DownloadProgressHandler? = nil) async throws -> URL {
configureProgressTracking(progress: progress)
do {
let (localURL, urlResponse) = try await urlSession.download(from: url, delegate: sessionDelegate)
try validator.validateStatus(from: urlResponse)
return try validator.validateUrl(localURL)
} catch let error as NetworkingError {
throw error
} catch let error as URLError {
throw NetworkingError.urlError(error)
} catch {
throw NetworkingError.internalError(.unknown)
}
}
@discardableResult
public func downloadFileTask(url: URL, completion: @escaping((Result<URL, NetworkingError>) -> Void)) -> URLSessionDownloadTask {
return downloadFileTask(url: url, progress: nil, completion: completion)
}
@discardableResult
public func downloadFileTask(url: URL, progress: DownloadProgressHandler?, completion: @escaping ((Result<URL, NetworkingError>) -> Void)) -> URLSessionDownloadTask {
configureProgressTracking(progress: progress)
let task = urlSession.downloadTask(with: url) { [weak self] localURL, response, error in
guard let self else {
completion(.failure(.internalError(.lostReferenceOfSelf)))
return
}
do {
try self.validator.validateNoError(error)
try self.validator.validateStatus(from: response)
let localURL = try self.validator.validateUrl(localURL)
completion(.success(localURL))
} catch let networkError as NetworkingError {
completion(.failure(networkError))
} catch let error as URLError {
completion(.failure(.urlError(error)))
} catch {
completion(.failure(.internalError(.unknown)))
}
}
task.resume()
return task
}
private func configureProgressTracking(progress: DownloadProgressHandler?) {
guard let progress else { return }
if sessionDelegate.downloadTaskInterceptor == nil {
sessionDelegate.downloadTaskInterceptor = fallbackDownloadTaskInterceptor
}
sessionDelegate.downloadTaskInterceptor?.progress = progress
}
}

How It Works

The new implementation simplifies progress tracking with these key steps:

  1. Call configureProgressTracking() to set up progress handling
  2. Check if a downloadTaskInterceptor is already provided in the sessionDelegate. If not, use the fallback interceptor
  3. Assign the progress closure to the interceptor

How to use it

With this update, clients can now track download progress with minimal code:

let fileDownloader = FileDownloader()
// Using async/await with progress tracking
do {
try await fileDownloader.downloadFile(with: fileURL, progress: { progress in
// Update UI with progress (0.0 to 1.0)
progressBar.progress = Float(progress)
percentLabel.text = "\(Int(progress * 100))%"
})
// Download complete
} catch {
// Handle error
}
// Using completion handler with progress tracking
fileDownloader.downloadFileTask(url: fileURL, progress: { progress in
// Update UI with progress (0.0 to 1.0)
progressBar.progress = Float(progress)
percentLabel.text = "\(Int(progress * 100))%"
}) { result in
switch result {
case .success(let fileURL):
// Handle downloaded file
case .failure(let error):
// Handle error
}
}

You can still create your own DownloadTaskInterceptor and pass it into the SessionDelegate just like before, and that would provide you an even finer grain of control, but if you don’t want or need to intercept download requests and just want a simple progress tracker via closure, this new solution works perfectly as well.

Conclusion

Adding progress tracking to the FileDownloader significantly enhances the user experience when downloading files. By using a closure-based approach, we’ve made it simple for developers to implement progress tracking without needing to create custom interceptors.

This improvement maintains backward compatibility while providing a more streamlined API. Clients can easily incorporate progress tracking with just a few lines of code, whether they’re using async/await or completion handler-based approaches.

If you’d like to take a look at the GitHub repo, you can find that here: https://github.com/Aldo10012/EZNetworking

And if you are interested in reading other parts of this series, feel free to read those here:

Building an iOS SDK (EZNetworking)

9 stories

Learn more Building a Networking SDK — Part 8 — DownloadTask progress tracking

Leave a Reply