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
AsyncImagedownloads 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 fullphaseswitch 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
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
let url = URL(string: "https://picsum.photos/400")—URL(string:)is failable — it returnsnilfor malformed strings. The result isURL?.AsyncImage(url: url)acceptsURL?— if the URL is nil, it goes straight to the.failurephase. Using a typedURLrather than a raw string prevents runtime errors from typos in URL construction.AsyncImage(url: url) { phase in— The phase-based initializer. SwiftUI calls this closure multiple times: first with.emptywhen the download begins, then with.successor.failurewhen it completes. Each call re-evaluates the closure and renders the appropriate view.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.case .success(let returnedImage):— The associated valuereturnedImageis a SwiftUIImage, not aUIImage. 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)..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:.cornerRadiusclips the view at its current bounds.case .failure:— The failure case doesn't need the associatedErrorvalue 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).default: Image(systemName: "questionmark")— Required becauseAsyncImagePhaseis not frozen — Apple may add cases in future SDKs. Without adefaultcase, your code would fail to compile after a hypothetical SDK update.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 performanceAsyncImage 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 argumentAsyncImage 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
AsyncImagehandles remote image downloading, caching, and state management natively in iOS 15+ — no third-party libraries needed for basic use cases.- Always handle all three
AsyncImagePhasecases:.emptyfor loading,.successfor the loaded image, and.failurefor network or URL errors — this makes your UI robust in offline and error conditions. - Call
.resizable()on theImagefrom.successbefore any size modifiers, and useURL(string:)to safely convert string URLs rather than force-casting.
Last updated: June 27, 2026