DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Image Loading Pipeline (AsyncImage Is Not Enough)

Images are the #1 cause of jank in SwiftUI apps.

If you’ve ever seen:

  • scrolling hitch in lists
  • images flashing in and out
  • blurry placeholders
  • sudden memory spikes
  • network storms on scroll

…your image pipeline is the problem.

AsyncImage is fine for demos — but it is not a production pipeline.

This post shows how to build a real, production-grade image loading system in SwiftUI:

  • async loading
  • decoding off the main thread
  • multi-layer caching
  • prefetching
  • cancellation
  • list-friendly behavior

🧠 The Core Principle

Images are data + decoding + memory + layout.

If you ignore any one of these, performance suffers.

A real pipeline has stages.


🧱 The Proper Image Pipeline

View

ImageLoader

Memory Cache

Disk Cache

Network

Plus:

  • background decoding
  • cancellation
  • prefetching

📦 1. Define an Image Loader

protocol ImageLoading {
    func load(url: URL) async throws -> UIImage
}
Enter fullscreen mode Exit fullscreen mode

Concrete implementation:

final class ImageLoader: ImageLoading {
    private let cache: ImageCache
    private let session: URLSession

    init(cache: ImageCache, session: URLSession = .shared) {
        self.cache = cache
        self.session = session
    }

    func load(url: URL) async throws -> UIImage {
        if let cached = cache.get(url) {
            return cached
        }

        let (data, _) = try await session.data(from: url)
        let image = try decode(data)
        cache.set(url, image)
        return image
    }
}
Enter fullscreen mode Exit fullscreen mode

🧠 2. Memory Cache for Images

final class ImageCache {
    private var storage: [URL: UIImage] = [:]

    func get(_ url: URL) -> UIImage? {
        storage[url]
    }

    func set(_ url: URL, _ image: UIImage) {
        storage[url] = image
    }

    func clear() {
        storage.removeAll()
    }
}
Enter fullscreen mode Exit fullscreen mode

Images are heavy.
Always cache them.


💾 3. Disk Cache for Images

Images are perfect disk cache candidates.

final class ImageDiskCache {
    private let directory = FileManager.default.urls(
        for: .cachesDirectory,
        in: .userDomainMask
    ).first!

    func url(for key: String) -> URL {
        directory.appendingPathComponent(key)
    }

    func write(_ data: Data, for key: String) throws {
        try data.write(to: url(for: key))
    }

    func read(for key: String) throws -> Data {
        try Data(contentsOf: url(for: key))
    }
}
Enter fullscreen mode Exit fullscreen mode

Key = hash of URL.


⚙️ 4. Decode Off the Main Thread

Never decode images on the main thread.

func decode(_ data: Data) throws -> UIImage {
    try Task.checkCancellation()

    return try await withCheckedThrowingContinuation { continuation in
        DispatchQueue.global(qos: .userInitiated).async {
            if let image = UIImage(data: data) {
                continuation.resume(returning: image)
            } else {
                continuation.resume(throwing: ImageError.decodeFailed)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This alone removes most scrolling jank.


🔄 5. Cancellation Is Mandatory

When a row disappears, the image load must cancel.

.task(id: url) {
    image = try? await loader.load(url: url)
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI cancels the task automatically when the view disappears.

Never use:

Task { }
Enter fullscreen mode Exit fullscreen mode

without tying it to view lifetime.


🧭 6. Prefetching for Lists

Before a cell appears, load its image.

func prefetch(urls: [URL]) {
    Task {
        for url in urls {
            _ = try? await loader.load(url: url)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Trigger from:

  • onAppear of previous cells
  • scroll position
  • pagination events

This makes lists feel instant.


🧩 7. SwiftUI View Wrapper

struct RemoteImage: View {
    let url: URL
    @State private var image: UIImage?

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFill()
            } else {
                placeholder
            }
        }
        .task(id: url) {
            image = try? await loader.load(url: url)
        }
    }

    var placeholder: some View {
        Color.gray.opacity(0.2)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is your new AsyncImage.


🚫 8. Why AsyncImage Fails at Scale

AsyncImage:

  • no disk cache
  • no memory control
  • no prefetching
  • no decoding control
  • poor list performance
  • no cancellation tuning

It’s fine for:

  • prototypes
  • demos
  • static screens

Not fine for:

  • feeds
  • galleries
  • grids
  • production apps

🧪 9. Testing the Pipeline

Because everything is abstracted:

struct MockImageLoader: ImageLoading {
    func load(url: URL) async throws -> UIImage {
        UIImage(systemName: "photo")!
    }
}
Enter fullscreen mode Exit fullscreen mode

Inject into previews and tests.


❌ 10. Common Anti-Patterns

Avoid:

  • loading images in init
  • decoding on main thread
  • no caching
  • no cancellation
  • global image singletons
  • using Data(contentsOf:)
  • letting lists trigger duplicate loads

These destroy performance.


🧠 Mental Model

Think in stages:

URL
 → Fetch
   → Decode
     → Cache
       → Render
Enter fullscreen mode Exit fullscreen mode

Each stage must be:

  • async
  • cancellable
  • isolated
  • testable

🚀 Final Thoughts

A real image pipeline gives you:

  • smooth scrolling
  • instant display
  • lower memory
  • lower bandwidth
  • happier users
  • fewer bugs

Once you fix images, your entire app feels faster.

Top comments (0)