Skip to content

Async Image in iOS 15 for SwiftUI | SwiftUI Bootcamp #54

Loading images from the internet used to require third-party libraries or significant URLSession plumbing in SwiftUI. iOS 15 introduced AsyncImage — a built-in view that downloads, caches, and displays remote images with loading and error states built right in. After this lesson you'll know how to use both forms of AsyncImage and handle all three loading phases gracefully.

What You'll Learn

  • How AsyncImage downloads and displays a remote image from a URL automatically
  • How to handle all three loading phases: .empty (loading), .success (loaded), and .failure (error)
  • How to style the loaded image with modifiers like .resizable(), .scaledToFit(), and .cornerRadius()
  • The difference between the simple content:placeholder: form and the full phase switch form

Mental Model

Think of AsyncImage like ordering food at a restaurant. You place the order (provide the URL), and while you wait, the table has a placeholder (a folded napkin, a "Loading…" indicator). When the food arrives (image loads successfully), the napkin is replaced with the actual dish. If the kitchen can't fill the order (network failure or bad URL), a "Sorry, not available" card appears instead.

The phase enum is the waiter's status report: "still cooking" (.empty), "here's your dish" (.success(Image)), or "we're out of that" (.failure(Error)). You handle each case and provide the appropriate view. The restaurant (the OS) handles all the actual downloading and caching — you just design what each status looks like on the table.

Detailed Explanation

AsyncImage is a native SwiftUI view introduced in iOS 15. It takes a URL (or String URL), performs an HTTP GET request in the background, and drives its display based on the current loading phase. It handles caching through the system URL cache, so images that have been loaded recently don't require re-downloading.

The phase-based initializer AsyncImage(url:) { phase in switch phase { … } } is the most flexible form. AsyncImagePhase is an enum with three cases: .empty (image hasn't loaded yet — show a placeholder), .success(let returnedImage) (image loaded — the associated value is a SwiftUI Image that can be styled), and .failure (loading failed — show an error indicator). There's also a default case needed for exhaustive switch coverage because new cases could be added in future OS versions.

The simpler form AsyncImage(url:, content:, placeholder:) is appropriate when you only care about the success and waiting states — it doesn't give you access to failure details. Use this form for non-critical decorative images where errors are silently acceptable. Use the phase form when you need to communicate failures to users.

AsyncImage does not support custom caching policies, authentication, headers, or request timeouts — it uses URLSession.shared under the hood with default settings. For production apps with image-heavy feeds, consider a library like Kingfisher or Nuke that provides disk caching, resizing, prefetching, and custom pipeline support. AsyncImage is ideal for occasional images, settings screens, and prototypes.

Code Structure

AsyncImageBoocamp.swift demonstrates the full phase-based form of AsyncImage. It loads from https://picsum.photos/400 (a test image service that returns a random photo), shows a ProgressView while loading, renders the image with corner radius and size constraints on success, and shows a question mark icon on failure. The commented-out block shows the simpler content:placeholder: form for comparison.

Complete Code

AsyncImageBoocamp.swift

swift
import SwiftUI

/*
 case empty -> No image is loaded.
 case success(Image) -> An image succesfully loaded.
 case failure(Error) -> An image failed to load with an error.
 */

struct AsyncImageBoocamp: View {
    
    let url = URL(string: "https://picsum.photos/400") // URL? — returns nil for malformed strings; safe to pass to AsyncImage
    
    var body: some View {
        AsyncImage(url: url) { phase in // phase is AsyncImagePhase, updated automatically as the download progresses
            switch phase {
            case .empty:
                ProgressView() // shown while the image is downloading; automatically hidden when done
            case .success(let returnedImage):
                returnedImage       // the downloaded Image; must call .resizable() before applying size modifiers
                    .resizable()
                    .scaledToFit()
                    .frame(width: 100, height: 100)
                    .cornerRadius(20) // applies after the image is sized, so the radius is consistent regardless of source resolution
            case .failure:
                Image(systemName: "questionmark") // fallback for 404s, timeouts, invalid URLs, or offline mode
                    .font(.headline)
            default:
                Image(systemName: "questionmark") // required for exhaustive switch; covers any future phases Apple adds
                    .font(.headline)
            }
        }
//        AsyncImage(url: url, content: { returnedImage in
//            returnedImage
//                .resizable()
//                .scaledToFit()
//                .frame(width: 100, height: 100)
//                .cornerRadius(20)
//        }, placeholder: {
//            ProgressView() // simpler form: only handles success and loading; no failure state
//        })
    }
}

struct AsyncImageBoocamp_Previews: PreviewProvider {
    static var previews: some View {
        AsyncImageBoocamp()
    }
}

Code Walkthrough

  1. let url = URL(string: "https://picsum.photos/400")URL(string:) is failable — it returns nil for malformed strings. The result is URL?. AsyncImage(url: url) accepts URL? — if the URL is nil, it goes straight to the .failure phase. Using a typed URL rather than a raw string prevents runtime errors from typos in URL construction.

  2. AsyncImage(url: url) { phase in — The phase-based initializer. SwiftUI calls this closure multiple times: first with .empty when the download begins, then with .success or .failure when it completes. Each call re-evaluates the closure and renders the appropriate view.

  3. case .empty: ProgressView() — The loading state. ProgressView() without arguments renders the system circular spinner. This is shown immediately while the image downloads and replaced as soon as the download finishes.

  4. case .success(let returnedImage): — The associated value returnedImage is a SwiftUI Image, not a UIImage. You must call .resizable() on it before any .frame() or .scaledToFit() modifiers — without .resizable(), the image renders at its intrinsic pixel dimensions (possibly much larger than the screen).

  5. .cornerRadius(20) — Applied after .frame() so the corner radius is applied to the constrained size, not the original image size. Modifier order in SwiftUI matters: .cornerRadius clips the view at its current bounds.

  6. case .failure: — The failure case doesn't need the associated Error value here (it's discarded with an empty binding), but in a production app you might want to log the error or show different messages for different failure types (no internet vs. 404 vs. timeout).

  7. default: Image(systemName: "questionmark") — Required because AsyncImagePhase is not frozen — Apple may add cases in future SDKs. Without a default case, your code would fail to compile after a hypothetical SDK update.

  8. Commented-out simple form — The content:placeholder: form is cleaner when you only care about success and loading. It doesn't expose the failure phase, which is acceptable for non-critical images. The trade-off is less control over error UI.

Common Mistakes

Mistake: Forgetting .resizable() before size modifiers, causing the image to render at full native resolution
A 400×400 pixel photo loaded via AsyncImage renders at 400 logical points without .resizable(). On a 3x Retina device that's a large portion of the screen. Always call .resizable() immediately on returnedImage before applying .scaledToFit() or .frame().

Mistake: Using AsyncImage in a tight scroll view loop without considering performance
AsyncImage creates a new download task for each URL. In a list with 100 items, this means 100 concurrent requests. AsyncImage uses the system URL cache, so images fetched recently are served from memory/disk. But for large feeds you should use a dedicated image loading library with proper prefetching, disk caching, and request deduplication.

Mistake: Passing a raw String as the URL argument
AsyncImage requires URL?, not String. URL(string:) is failable — always use it to convert string URLs. An invalid URL string will silently result in a nil URL, which causes AsyncImage to show the failure state without any useful error message.

Key Takeaways

  • AsyncImage handles remote image downloading, caching, and state management natively in iOS 15+ — no third-party libraries needed for basic use cases.
  • Always handle all three AsyncImagePhase cases: .empty for loading, .success for the loaded image, and .failure for network or URL errors — this makes your UI robust in offline and error conditions.
  • Call .resizable() on the Image from .success before any size modifiers, and use URL(string:) to safely convert string URLs rather than force-casting.

Last updated: June 27, 2026

Released under the MIT License.