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
}
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
}
}
🧠 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()
}
}
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))
}
}
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)
}
}
}
}
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)
}
SwiftUI cancels the task automatically when the view disappears.
Never use:
Task { }
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)
}
}
}
Trigger from:
-
onAppearof 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)
}
}
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")!
}
}
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
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)