How to implement async image in SwiftUI
Use AsyncImage(url:) to load a remote image from a URL with zero configuration — SwiftUI handles downloading, caching in memory, and displaying a placeholder while loading. For full control over loading states and errors, use the phase-based closure overload.
import SwiftUI
struct AvatarView: View {
let url = URL(string: "https://picsum.photos/200")
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image.resizable().scaledToFill()
case .failure:
Image(systemName: "photo.badge.exclamationmark")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 80, height: 80)
.clipShape(Circle())
}
}
Full implementation
The example below builds a reusable RemoteImageView that accepts any URL, renders a shimmer-style skeleton while loading, gracefully shows an error state on failure, and exposes a contentMode parameter so callers can switch between fill and fit. The phase-based AsyncImage closure is the right tool here: it gives you a strongly-typed AsyncImagePhase enum rather than separate placeholder and content closures, which scales better when error handling matters. All image modifiers are applied inside the .success case, so they only run once a real image is available.
import SwiftUI
// MARK: - Reusable remote image view
struct RemoteImageView: View {
let url: URL?
var contentMode: ContentMode = .fill
var cornerRadius: CGFloat = 12
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ShimmerView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: contentMode)
case .failure(let error):
VStack(spacing: 8) {
Image(systemName: "photo.badge.exclamationmark")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(error.localizedDescription)
.font(.caption2)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGray6))
@unknown default:
EmptyView()
}
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.animation(.easeInOut(duration: 0.25), value: url)
}
}
// MARK: - Shimmer placeholder
struct ShimmerView: View {
@State private var phase: CGFloat = 0
var body: some View {
Rectangle()
.fill(
LinearGradient(
colors: [
Color(.systemGray5),
Color(.systemGray4),
Color(.systemGray5)
],
startPoint: .init(x: phase - 0.3, y: 0.5),
endPoint: .init(x: phase + 0.3, y: 0.5)
)
)
.onAppear {
withAnimation(
.linear(duration: 1.2).repeatForever(autoreverses: false)
) {
phase = 1.3
}
}
}
}
// MARK: - Usage in a list
struct PhotoFeedView: View {
struct Photo: Identifiable {
let id: Int
var imageURL: URL? {
URL(string: "https://picsum.photos/seed/\(id)/400/300")
}
}
let photos = (1...20).map { Photo(id: $0) }
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(photos) { photo in
RemoteImageView(url: photo.imageURL)
.frame(height: 200)
.accessibilityLabel("Photo \(photo.id)")
}
}
.padding(.horizontal, 16)
}
.navigationTitle("Photo Feed")
}
}
}
#Preview("Photo Feed") {
PhotoFeedView()
}
#Preview("Single Image") {
RemoteImageView(
url: URL(string: "https://picsum.photos/seed/42/400/300"),
contentMode: .fit,
cornerRadius: 20
)
.frame(width: 320, height: 240)
.padding()
}
#Preview("Error State") {
RemoteImageView(url: URL(string: "https://invalid.example.com/img.jpg"))
.frame(width: 200, height: 150)
.padding()
}
How it works
-
Phase-based closure —
AsyncImage(url:) { phase in … }gives you a singleAsyncImagePhasevalue that transitions through.empty(not yet loaded),.success(Image), and.failure(Error). Switching over it is exhaustive and future-proof thanks to the@unknown defaultcase. -
Animated transition — The
.animation(.easeInOut, value: url)modifier onRemoteImageViewcross-fades between the shimmer, the loaded image, or the error state whenever the URL changes — ideal for paginated feeds that swap out the same view with different URLs. -
Shimmer effect —
ShimmerViewuses aLinearGradientwhose start and end points are driven by an animatedphasestate variable. Incrementingphasefrom0to1.3in a repeating linear animation slides the highlight band left-to-right across the placeholder rectangle. -
Lazy loading — Wrapping
RemoteImageViewin aLazyVStackmeansAsyncImageonly fires network requests as rows scroll into view, keeping memory and bandwidth usage minimal in long lists. -
Accessibility — Each list item carries an explicit
.accessibilityLabelbecauseAsyncImagedoes not infer a meaningful label from a URL. Screen readers will announce "Photo 1", "Photo 2", etc. instead of a raw URL string.
Variants
Custom URL cache with URLSession
By default AsyncImage uses the shared URLSession and its in-memory cache. Pass a custom URLSession via the transaction overload to use a larger on-disk cache or add request headers such as authorization tokens.
import SwiftUI
// Configure a persistent 100 MB disk cache once at app launch
extension URLSession {
static let imageSession: URLSession = {
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 20 * 1024 * 1024, // 20 MB
diskCapacity: 100 * 1024 * 1024, // 100 MB
diskPath: "com.soarias.imagecache"
)
config.httpAdditionalHeaders = ["Accept": "image/*"]
return URLSession(configuration: config)
}()
}
struct CachedRemoteImage: View {
let url: URL?
var body: some View {
AsyncImage(
url: url,
urlSession: .imageSession
) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .empty:
Color(.systemGray5)
case .failure:
Image(systemName: "wifi.slash")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
}
}
#Preview {
CachedRemoteImage(url: URL(string: "https://picsum.photos/300/200"))
.frame(width: 300, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
}
Transaction-based animation (cross-fade on load)
Use AsyncImage(url:transaction:content:) and pass a Transaction with a custom animation to override how the image enters. Setting transaction: Transaction(animation: .spring(duration: 0.4)) causes the loaded image to bounce-in rather than snap into place. This overload is particularly useful inside List rows where SwiftUI's default implicit animations can conflict with your custom transitions.
Common pitfalls
-
iOS version:
AsyncImagewas introduced in iOS 15, but theurlSession:parameter that lets you supply a custom cache or headers requires iOS 17+. If you need custom session configuration and target iOS 15/16, wrapURLSession.dataTaskmanually instead. -
Applying modifiers outside the success case: It's tempting to write
AsyncImage(url: url).resizable(), but.resizable()is anImage-only modifier. Calling it on theAsyncImagewrapper will cause a compile error. Always apply image-specific modifiers inside the.success(let image)branch:image.resizable().scaledToFill(). -
Performance in large grids:
AsyncImagedoes not batch or throttle concurrent downloads. In aLazyVGridwith hundreds of cells, scroll speed can trigger dozens of simultaneous requests. Throttle via a customURLSessionby settingconfig.httpMaximumConnectionsPerHost = 6, or consider a third-party image library (Nuke, Kingfisher) for explicit request queuing. -
Missing accessibility labels:
AsyncImagegenerates no implicitaccessibilityLabel. Always add.accessibilityLabel("Description")at the call site, or your images will be announced as unlabelled by VoiceOver.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement async image loading in SwiftUI for iOS 17+. Use AsyncImage with the phase-based closure overload. Support a shimmer placeholder, error state with icon + message, and a configurable content mode (fill or fit). Configure a persistent URLCache via a custom URLSession. Make it accessible (VoiceOver labels on every call site). Add a #Preview with realistic sample data using picsum.photos URLs.
Drop this prompt into Soarias during the Build phase after your screens are scaffolded — it produces a production-ready RemoteImageView component that slots directly into your LazyVStack or LazyVGrid without further editing.
Related
FAQ
Does this work on iOS 16?
The core AsyncImage view is available back to iOS 15, so the phase-based closure and all image modifiers shown here run on iOS 15 and 16. However, the urlSession: parameter — which lets you supply a custom URLSession for on-disk caching or auth headers — requires iOS 17+. If you need to support iOS 15/16 with a custom session, download the data yourself using URLSession.data(from:) inside a .task modifier and render the image from the returned Data.
Can I cancel an in-flight AsyncImage download?
Yes — SwiftUI automatically cancels the underlying download task when the view leaves the hierarchy (e.g., a row scrolls off screen in a LazyVStack). If you need manual cancellation, switch to a @State var imageTask: Task<Void, Never>? pattern using URLSession.data(from:) directly, which gives you a cancellable Task handle you can call .cancel() on.
What's the UIKit equivalent?
UIKit has no direct equivalent — you would typically use URLSession.dataTask(with:completionHandler:) to fetch image data, convert it to UIImage, and set it on a UIImageView on the main queue, remembering to cancel the task in prepareForReuse() on cells. Third-party libraries like SDWebImage and Kingfisher wrap this boilerplate and provide disk caching. AsyncImage is SwiftUI's clean, built-in answer to what those libraries solve in UIKit.
Last reviewed: 2026-05-11 by the Soarias team.