Three ways to download images in Swift
Compare escaping closures, Combine, and async/await by implementing the same image download.
Read the Korean original on Velog ↗English translation

Even downloading a single image can look very different depending on the asynchronous pattern. I compared escaping closures, Combine, and async/await to see how each affects control flow and error handling.
Escaping closures
A completion handler is a traditional way to deliver the result of an asynchronous operation. Captures such as [weak self] help manage object lifetimes. This approach needs no additional reactive framework, but nested operations and error handling can become difficult to follow.
class DownloadImageAsyncImageLoader {
let url = URL(string: "https://picsum.photos/200")!
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 {
return nil
}
return image
}
func downloadWithEscaping(completionHandler: @escaping (_ image: UIImage?, _ error: Error?) -> Void) {
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
let image = self?.handleResponse(data: data, response: response)
completionHandler(image, error)
}
.resume()
}
}
class DownloadImageAsyncViewModel: ObservableObject {
@Published var image: UIImage? = nil
let loader = DownloadImageAsyncImageLoader()
func fetchImage() {
loader.downloadWithEscaping { [weak self] image, error in
DispatchQueue.main.async {
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: 240, height: 240)
}
}
.onAppear {
viewModel.fetchImage()
}
}
}
Combine
Combine models asynchronous work as streams of values. Subscriptions and cancellation provide a structured way to manage that work and its resources. It is available from iOS 13 and introduces concepts that take time to learn.
class DownloadImageAsyncImageLoader {
let url = URL(string: "https://picsum.photos/200")!
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 {
return nil
}
return image
}
func downloadWithCombine() -> AnyPublisher<UIImage?, Error> {
URLSession.shared.dataTaskPublisher(for: url)
.map(handleResponse)
.mapError { $0 }
.eraseToAnyPublisher()
}
}
class DownloadImageAsyncViewModel: ObservableObject {
@Published var image: UIImage? = nil
let loader = DownloadImageAsyncImageLoader()
var cancellables = Set<AnyCancellable>()
func fetchImage() {
loader.downloadWithCombine()
.receive(on: DispatchQueue.main)
.sink { _ in
} receiveValue: { [weak self] image in
self?.image = image
}
.store(in: &cancellables)
}
}
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: 240, height: 240)
}
}
.onAppear {
viewModel.fetchImage()
}
}
}
Async/await
Async/await lets asynchronous code read much like sequential code, making control flow and error handling easier to follow. Tasks support asynchronous work and priorities, while MainActor provides isolation for UI-related state. Adoption depends on the deployment targets and APIs used by a project.
class DownloadImageAsyncImageLoader {
let url = URL(string: "https://picsum.photos/200")!
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 {
return nil
}
return image
}
func downloadWithAsync() async throws -> UIImage? {
do {
let (data, response) = try await URLSession.shared.data(from: url)
return handleResponse(data: data, response: response)
} catch {
throw error
}
}
}
class DownloadImageAsyncViewModel: ObservableObject {
@Published var image: UIImage? = nil
let loader = DownloadImageAsyncImageLoader()
var cancellables = Set<AnyCancellable>()
func fetchImage() async {
let image = try? await loader.downloadWithAsync()
await MainActor.run {
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: 240, height: 240)
}
}
.onAppear {
Task {
await viewModel.fetchImage()
}
}
}
}