```html SwiftUI: How to Async Image (iOS 17+, 2026)

How to implement async image in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: AsyncImage Updated: May 11, 2026
TL;DR

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

  1. Phase-based closureAsyncImage(url:) { phase in … } gives you a single AsyncImagePhase value that transitions through .empty (not yet loaded), .success(Image), and .failure(Error). Switching over it is exhaustive and future-proof thanks to the @unknown default case.
  2. Animated transition — The .animation(.easeInOut, value: url) modifier on RemoteImageView cross-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.
  3. Shimmer effectShimmerView uses a LinearGradient whose start and end points are driven by an animated phase state variable. Incrementing phase from 0 to 1.3 in a repeating linear animation slides the highlight band left-to-right across the placeholder rectangle.
  4. Lazy loading — Wrapping RemoteImageView in a LazyVStack means AsyncImage only fires network requests as rows scroll into view, keeping memory and bandwidth usage minimal in long lists.
  5. Accessibility — Each list item carries an explicit .accessibilityLabel because AsyncImage does 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

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.

```