Skip to content

Download images with Async/Await, @escaping, and Combine | Modern Swift Concurrency #2

Downloading an image sounds simple, but it exposes every rough edge of asynchronous Swift: you need to start work off the main thread, handle success and failure, and return to the main thread to update the UI — this lesson shows the same operation written three ways (completion handlers, Combine, async/await) so you can see exactly why each successive approach is cleaner and safer.

What You'll Learn

  • How @escaping completion handlers work and why they require [weak self], manual thread-hopping, and careful memory management
  • How Combine wraps the same network call in a publisher pipeline and why it is powerful but verbose for simple one-shot fetches
  • How async/await with URLSession.data(from:) collapses all three concerns into a flat, readable function that the compiler can reason about

Mental Model

Imagine ordering a custom photo print. With a completion handler (@escaping), you give the shop your phone number (a callback closure) and leave. When the print is ready, they call you — but you have to remember to check if you're still at home (weak self) and manually walk back to the frame shop yourself (dispatch to main). With Combine, you subscribe to a publisher — the shop streams updates to your mailbox (the sink subscriber) and you can transform them in a pipeline, but setting up the subscription is verbose for a single photo. With async/await, you call await pickUpPrint() and just... wait — the runtime suspends your task, no callback, no subscription, no manual thread hopping. When the print is ready, you're automatically back where you started.

Detailed Explanation

The @escaping closure pattern was the standard way to handle asynchronous work in Swift before async/await. The @escaping annotation tells the compiler that the closure will outlive the function call that created it — it "escapes" the function's scope to be called later. This is necessary because the URLSession.dataTask callback fires on a URLSession's delegate queue (a background thread), long after downloadWithEscaping has returned. The practical consequences are significant: you need [weak self] to avoid retain cycles (the closure holds a reference to self, which may hold a reference to the closure), and you need DispatchQueue.main.async to return to the main thread for UI updates. Both of these are manual, error-prone steps that the compiler cannot verify.

Combine's dataTaskPublisher wraps the same network call in a reactive pipeline. You can chain .map, .decode, .replaceError, and .receive(on:) operators in a fluent style. The .receive(on: DispatchQueue.main) operator handles the thread-hop declaratively, which is cleaner than a manual DispatchQueue.main.async. However, you must store the AnyCancellable in a Set<AnyCancellable> or the subscription cancels immediately. For a one-shot image download, the ceremony of setting up a publisher, subscribing, and storing the cancellable is substantial overhead.

URLSession.data(from:) is the async/await counterpart to dataTask(with:). It is a native async throws function added to URLSession in Swift 5.5. It suspends the calling task until the network response arrives, then resumes with a (Data, URLResponse) tuple — or throws a URLError if something goes wrong. The result: no callback, no subscription, no [weak self], no DispatchQueue.main.async buried inside a closure. Thread management is handled by the cooperative thread pool and @MainActor/MainActor.run.

The handleResponse helper is shared across all three implementations because response validation logic is identical regardless of the async mechanism used. It validates that data was received, that it decodes to a UIImage, and that the HTTP status code is 2xx. This kind of extraction is a good real-world pattern: keep your async layer thin and push validation into synchronous, testable helpers.

Code Structure

DownloadImageAsync.swift splits responsibilities cleanly. DownloadImageAsyncImageLoader is a pure network layer with three download methods (escaping, Combine, async) plus a shared handleResponse helper. DownloadImageAsyncViewModel calls the async version and updates @Published image state. DownloadImageAsync renders the image. The commented-out code in fetchImage() preserves the older implementation patterns side-by-side so you can compare them directly.

Complete Code

DownloadImageAsync.swift

swift
import SwiftUI
import Combine

class DownloadImageAsyncImageLoader {
    
    let url = URL(string: "https://picsum.photos/200")! // force-unwrap is acceptable for a hardcoded literal known to be valid
    
    // Shared validation: used by all three download approaches so validation logic isn't duplicated
    func handleResponse(data: Data?, response: URLResponse?) -> UIImage? {
        guard
            let data = data,
            let image = UIImage(data: data),
            let response = response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else { // reject non-2xx responses as failures
                return nil
            }
        return image
    }
    
    // Approach 1: @escaping closure — the callback fires on URLSession's background delegate queue, not the main thread
    func downloadWithEscaping(completionHandler: @escaping (_ image: UIImage?, _ error: Error?) -> ()) {
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            let image = self?.handleResponse(data: data, response: response) // [weak self] prevents a retain cycle
            completionHandler(image, error) // caller is responsible for dispatching to main before touching UI
        }
        .resume() // dataTask is created suspended; .resume() actually starts the network request
    }
    
    // Approach 2: Combine — declarative pipeline, but requires storing AnyCancellable to keep the subscription alive
    func downloadWithCombine() -> AnyPublisher<UIImage?, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .map(handleResponse) // transform (Data, URLResponse) -> UIImage? using the shared helper
            .mapError({ $0 }) // re-emit URLError as the generic Error type to satisfy AnyPublisher's type parameter
            .eraseToAnyPublisher() // hide the concrete publisher type from callers
    }
    
    // Approach 3: async/await — URLSession.data suspends the task; no callback, no retain cycle, no manual thread hop
    func downloadWithAsync() async throws -> UIImage? {
        do {
            let (data, response) = try await URLSession.shared.data(from: url, delegate: nil) // suspends here until network responds
            return handleResponse(data: data, response: response)
        } catch {
            throw error // re-throw so callers can handle URLErrors (timeout, no connection, etc.)
        }
    }
    
}

class DownloadImageAsyncViewModel: ObservableObject {
    
    @Published var image: UIImage? = nil
    let loader = DownloadImageAsyncImageLoader()
    var cancellables = Set<AnyCancellable>() // must be retained; releasing this cancels any active Combine subscriptions
    
    func fetchImage() async {
        /*
//        loader.downloadWithEscaping { [weak self] image, error in
//            DispatchQueue.main.async {  // manually hop back to main — forgetting this causes a UIKit threading violation
//                self?.image = image
//            }
//        }
        
//        loader.downloadWithCombine()
//            .receive(on: DispatchQueue.main)  // declarative thread hop — cleaner than DispatchQueue.main.async inside a closure
//            .sink { _ in
//
//            } receiveValue: { [weak self] image in
//                self?.image = image
//            }
//            .store(in: &cancellables)  // without .store, the subscription is immediately cancelled and the image never loads
        */
        
        let image = try? await loader.downloadWithAsync() // try? converts any network error into nil; image shows nothing on failure
        await MainActor.run { // explicit hop to main actor — needed because this ViewModel is not @MainActor annotated
            self.image = image
        }
    }
    
}

struct DownloadImageAsync: View {
    
    @StateObject private var viewModel = DownloadImageAsyncViewModel()
    
    var body: some View {
        ZStack {
            if let image = viewModel.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 250, height: 250)
            }
        }
        .onAppear {
            Task { // creates an unstructured Task; this is a common pattern but .task modifier would handle cancellation automatically
                await viewModel.fetchImage()
            }
        }
    }
}

struct DownloadImageAsync_Previews: PreviewProvider {
    static var previews: some View {
        DownloadImageAsync()
    }
}

Code Walkthrough

  1. handleResponse — the shared validation helper — All three download approaches feed their raw (Data?, URLResponse?) into this function. By extracting validation here, you guarantee that escaping, Combine, and async paths all apply identical validation rules. This function is synchronous and pure — it makes no async calls, so it can be tested without networking infrastructure.

  2. downloadWithEscaping — how the old pattern worksURLSession.dataTask fires the closure on a background thread, which is why the commented-out callsite wraps everything in DispatchQueue.main.async. Notice [weak self] in the capture list: without it, the closure strongly retains self, self holds the loader, and neither is released. With async/await, none of this bookkeeping is needed.

  3. downloadWithCombine — the pipeline approach — The .map(handleResponse) shorthand passes the handleResponse method itself as the mapping function, using Swift's first-class function support. .eraseToAnyPublisher() is a type-erasure step: without it, the return type would be a deeply nested generic that leaks implementation details to callers.

  4. downloadWithAsync — the modern approachtry await URLSession.shared.data(from: url, delegate: nil) is one line that does the work of the entire escaping block. The task suspends at the try await expression — no thread is blocked — and resumes with either the downloaded data or a thrown error. The function signature async throws advertises both behaviors to callers.

  5. MainActor.run in fetchImage() — Because DownloadImageAsyncViewModel is not annotated with @MainActor, the compiler doesn't know that self.image = image must happen on the main thread. MainActor.run explicitly hops to the main actor for that assignment. In practice, annotating the whole ViewModel with @MainActor (as shown in lesson 0) is cleaner — you avoid scattering MainActor.run calls throughout your code.

  6. Task { await viewModel.fetchImage() } in onAppearonAppear's closure is synchronous, so a Task is required to bridge into async code. The downside of this pattern vs. .task is that if the view disappears before fetchImage() completes, this Task keeps running and updates viewModel.image even though nothing is observing it. For a simple one-off load this is often acceptable, but for navigation-driven views use .task.

Common Mistakes

Mistake: Forgetting DispatchQueue.main.async inside an @escaping completion handlerURLSession.dataTask callbacks arrive on a background thread. Assigning to @Published properties or calling any UIKit API from a background thread causes a runtime threading violation. In debug builds this often logs a purple runtime warning; in release builds it can silently corrupt UI state or crash. Async/await eliminates this risk entirely by tracking the actor context at compile time.

Mistake: Not storing the AnyCancellable from a Combine subscription If you call loader.downloadWithCombine().sink { ... } without .store(in: &cancellables), the returned AnyCancellable is immediately released, which cancels the subscription before the network response arrives. The result is a silent failure: the view stays empty and no error is reported. Always store cancellables for the lifetime of the owning object.

Mistake: Updating UI state on the wrong actor after migrating to async/await When converting @escaping code to async/await, developers sometimes drop the DispatchQueue.main.async wrapper without adding await MainActor.run or @MainActor to the ViewModel. The result is the same threading bug as before, but it now looks clean. Annotate ViewModels with @MainActor at the class level to make the entire surface area safe by default.

Key Takeaways

  • The @escaping pattern requires three pieces of manual threading discipline — [weak self], DispatchQueue.main.async, and .resume() — all of which are forgotten silently with no compiler error
  • Combine's .receive(on: DispatchQueue.main) is a cleaner declarative thread-hop, but its subscription lifecycle management (AnyCancellable storage) is a common source of subtle bugs
  • async/await with URLSession.data(from:) collapses callback, error propagation, and thread management into a single flat expression that the compiler fully understands

Last updated: June 27, 2026

Released under the MIT License.