Improve AsyncImage in SwiftUI

Photo by Viktor Talashuk on Unsplash *Just a cool image to catch your attention :) Photo by Viktor Talashuk on Unsplash *Just a cool image to catch your attention :)

Hi! Apple has given us a great AsyncImage, and it works well in most cases. But today, I’ll show you how to make it even better.

The problem

As you may know, AsyncImage loads an image every time it appears. But what if we need to show the same image multiple times on screen at once Do we really need to make multiple network requests for the same image? I don’t think so.

Instead, I’d like a solution that loads the image only once, no matter how many views request it. But how can we achieve that?

Solution

To get this behavior, we’ll build our own version of AsyncImage:

struct AsyncImage<Content: View>: View {
    @State var model: ViewModel
    
    private let content: (AsyncImagePhase) -> Content
    
    init(url: URL, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
        self.content = content
        self.model = ViewModel(url: url)
    }
    
    var body: some View {
        content(model.phase)
            .onAppear(perform: model.load)
    }
    
    @Observable
    final class ViewModel {
        var phase: AsyncImagePhase = .empty
        
        private let loader: ImageLoader
        private var url: URL
        private var cancelable: AnyCancellable?
        
        init(url: URL, loader: ImageLoader = ImageLoaderImpl.shared) {
            self.url = url
            self.loader = loader
        }
        
        func load() {
            cancelable = loader.load(url: url)
                .receive(on: DispatchQueue.main)
                .sink(receiveValue: { [weak self] phase in
                    self?.phase = phase
                })
        }
    }
}

protocol ImageLoader {
    func load(url: URL) -> AnyPublisher<AsyncImagePhase, Never>
}

This custom AsyncImage view uses a ViewModel that calls the load method from an ImageLoader service. I chose to reuse the existing AsyncImagePhase because it fits our needs perfectly. You will find AsyncView without ViewModel at the end of this article.

A basic loader

Let’s create a basic loader:

class ImageLoaderImpl: ImageLoader {
    enum Error: Swift.Error {
        case imageDecodingFailed
    }
    
    func load(url: URL) -> AnyPublisher<AsyncImagePhase, Never> {
        URLSession.shared
            .dataTaskPublisher(for: url)
            .map({ (data, _) in
                if let image = UIImage(data: data) {
                    return AsyncImagePhase.success(Image(uiImage: image))
                } else {
                    return .failure(Error.imageDecodingFailed)
                }
            })
            .catch { Just<AsyncImagePhase>(AsyncImagePhase.failure($0)) }
            .eraseToAnyPublisher()
    }
}

In this example, we use a dataTaskPublisher to fetch image data and convert it into an AsyncImagePhase, which we then feed into the view.

Sharing requests between views

So far, so good — but there’s a problem: Each instance of AsyncImage still triggers its own network request. To solve this, we need to share the same publisher for each URL.

In other words, we’ll cache active requests and return the same publisher if it’s already in progress.

Here’s how we can do that:

class ImageLoaderImpl: ImageLoader {
    enum Error: Swift.Error {
        case imageDecodingFailed
    }
    
    // Shared instance to provide a common cache for all views.
    // Since all views operate on the main thread, we don't have to worry about data races.
    static var shared = ImageLoaderImpl()
    
    // This dictionary stores the current publisher for a given URL,
    // along with a cancellable that removes the publisher from the dictionary
    // once the request completes.
    var loaders: [URL: (publisher: AnyPublisher<AsyncImagePhase, Never>, cancelable: AnyCancellable)] = [:]
    
    func load(url: URL) -> AnyPublisher<AsyncImagePhase, Never> {
        // If a publisher already exists for this URL, return it.
        if let (publisher, _) = loaders[url] {
            return publisher
        } else {
            // Otherwise, create a new one.

            // This subject will emit AsyncImagePhase values to the AsyncImage.
            let subject = CurrentValueSubject<AsyncImagePhase, Never>(.empty)

            // Erase the subject to AnyPublisher to hide the implementation details from subscribers.
            let publisher = subject.eraseToAnyPublisher()
            
            // Make the request via URLSession.dataTaskPublisher
            let cancelable = URLSession
                .shared.dataTaskPublisher(for: url)
                // Map the response to AsyncImagePhase
                .map({ (data, _) in
                    if let image = UIImage(data: data) {
                        return AsyncImagePhase.success(Image(uiImage: image))
                    } else {
                        return .failure(Error.imageDecodingFailed)
                    }
                })
                // Convert all errors into AsyncImagePhase
                .catch { Just<AsyncImagePhase>(AsyncImagePhase.failure($0)) }
                // Send all events to our shared subject
                .handleEvents(receiveOutput: subject.send)
                // Once we receive the final result, remove the publisher from the dictionary
                // and cancel the associated subscription
                .sink(receiveValue: { [weak self] _ in
                    self?.loaders[url]?.cancelable.cancel()
                    self?.loaders[url] = nil
                })
            
            // Store the publisher and subscription in the dictionary
            loaders[url] = (publisher: publisher, cancelable: cancelable)
            return publisher
        }
    }
}

Let’s break it down:

  1. We added a shared loaders dictionary that stores publishers and their corresponding cancelables.
  2. If a request is already in progress for a given URL, we return the same publisher.
  3. Once the request completes, we remove it from the cache.

This way, multiple views share the same ongoing request — no redundant calls, no wasted bandwidth.

Exactly what we wanted!

Final thoughts

Today you learn how to:

  1. Build your own AsyncImage.
  2. Convert one publisher into another.
  3. Optimize repeated server requests using Combine.

This principle isn’t limited to images — you can apply it to any repeatable request that benefits from caching.

Even if you’re not convinced it’s necessary, your backend teammates will definitely thank you. 😉

Cheers! 🍻

Update #1: The cache solution with no cache

Thanks Michael Long, I noticed that the response cache in this solution isn’t explained clearly enough. Since we’re using URLSession in this case, we already have a built-in caching solution. By default, URLSession follows HTTP cache-related headers to decide when it should or shouldn’t download the response body. It works out of the box — no need to add anything on top of that.

However, if you want to force URLSession to use the cache instead of even making a request (if there is something in the cache, of course), you can configure your own URLSession instance with the cache policy .returnCacheDataDontLoad.

Here’s how you can do that in this case:

final class ImageLoaderImpl: ImageLoader {
    ...
    private let urlSession: URLSession
    init() {
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .returnCacheDataDontLoad
        self.urlSession = URLSession(configuration: configuration)
    }
    ...

And now, it uses the cache whenever possible instead of making requests — ignoring cache related headers. You can also set up a custom URLCache for this URLSession if you want. However, I’d recommend sticking with URLSession.shared in this case to keep the behavior predictable.

Update #2: We don’t need MVVM in the AsyncView

Thanks Andrey Ilnitskiy for pointing out that we don’t need a ViewModel in this case — that’s a great insight. Here’s how we can reduce the size of our view by more than half by using AsyncSequence instead of an observed ViewModel, publishers, and other boilerplate.

struct AsyncImage<Content: View>: View {
    @State var phase: AsyncImagePhase = .empty

    private let loader: ImageLoader
    private let url: URL
    private let content: (AsyncImagePhase) -> Content
    
    init(url: URL, loader: ImageLoader = ImageLoaderImpl.shared, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
        self.url = url
        self.loader = loader
        self.content = content
    }
    
    var body: some View {
        content(phase)
            .task {
                for await phase in loader.load(url: url).values {
                    self.phase = phase
                }
            }
    }
}

Nice, clean, no unnecessary ViewModel — just how it should be. 🙂

P.S. Want to go deeper? Here are a few helpful links: