SwiftUI AsyncImage Placeholder and Failure States
Handle swiftui asyncimage placeholder views for slow loads and network failures using content phases, custom shapes, and deterministic fallback URLs.
SwiftUI's AsyncImage component handles remote image loading, but its default placeholder is a gray rectangle that conveys no context. Using the phase-based API, you can render a branded SwiftUI AsyncImage placeholder during loading and swap to a deterministic fallback URL when the network request fails.
This guide walks through the phase closure pattern, custom placeholder shapes, and pairing the failure case with fallback.pics URLs that match your UI's exact dimensions and color scheme.
Problem
Why the default AsyncImage placeholder falls short
AsyncImage has three states: empty (loading), success (image ready), and failure (network or decoding error). The default API collapses all three into a single content closure that only fires on success, leaving the empty and failure cases as a uniform gray box. In a product grid or avatar list, every card looks broken during load and stays broken on failure.
Unlike UIKit where you set a placeholder synchronously before the request starts, SwiftUI renders the view tree before data arrives. Without explicit handling, users see no visual feedback, layout jumps when the image loads, and error states are indistinguishable from loading states.
Fallback URLs fix the failure case by providing a predictable image that matches your card's dimensions. A branded fallback at exactly 400×400 prevents layout shift and gives the UI a consistent visual footprint regardless of network conditions.
Implementation
AsyncImage phase closure for placeholder and failure states
The phase-based initializer passes an AsyncImagePhase enum value to the content closure. You switch over .empty, .success(let image), and .failure to render different views for each case. The .failure case is where you inject your fallback URL.
Keep placeholder views dimensionally identical to the success state. A ProgressView() inside a frame that matches the expected image size prevents layout reflow when the image resolves.
import SwiftUI
struct FallbackImage: View {
let url: URL?
let width: CGFloat
let height: CGFloat
let fallbackURL: URL
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
// Skeleton placeholder while loading
Rectangle()
.fill(Color(.systemGray5))
.overlay(
ProgressView()
.progressViewStyle(.circular)
)
.frame(width: width, height: height)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
.clipped()
case .failure:
// Deterministic fallback from fallback.pics
AsyncImage(url: fallbackURL) { img in
img.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color(.systemGray5)
}
.frame(width: width, height: height)
.clipped()
@unknown default:
Color(.systemGray6)
.frame(width: width, height: height)
}
}
.cornerRadius(8)
}
}
// Usage
FallbackImage(
url: URL(string: product.imageURL),
width: 200,
height: 200,
fallbackURL: URL(string: "https://fallback.pics/api/v1/200x200/7C3AED/FFFFFF?text=No+Photo")!
) Retry logic
Refreshing failed images without infinite loops
Network failures are often transient. A pull-to-refresh mechanism or an explicit retry button gives users control without triggering automatic retry loops that hammer a server already under load.
Implement retry by toggling a UUID-based cache-busting query parameter on the URL. SwiftUI detects the URL change and restarts the fetch cycle. Store retry count in @State and cap retries at two attempts to avoid hammering a server that is genuinely down.
@State private var retryID = UUID()
@State private var retryCount = 0
var retryURL: URL? {
guard retryCount < 2, let base = originalURL else { return nil }
return URL(string: "\(base.absoluteString)?_retry=\(retryID)")
}
// In phase .failure:
if retryCount < 2 {
Button("Retry") {
retryID = UUID()
retryCount += 1
}
} Skeleton
Shimmer placeholder for image loading states
A shimmer animation during the .empty phase communicates that content is loading, not absent. Use a linear gradient mask animated with a phase offset to produce the sweep effect purely with SwiftUI APIs — no UIKit shimmer libraries required.
Set the shimmer frame to the exact image dimensions. This keeps layout stable across all three phases and ensures the content view doesn't reflow when the image resolves.
struct ShimmerBox: View {
@State private var phase: CGFloat = 0
var body: some View {
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(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
}
}
}
} Caching
URL caching and avoiding redundant network requests
AsyncImage uses URLSession.shared under the hood, which respects standard HTTP caching headers. Fallback.pics URLs return Cache-Control: public, max-age=31536000, immutable so successful fallback fetches are cached locally and never re-fetched during the session.
For frequently repeated URLs (avatar lists, product grids), wrap AsyncImage inside an observable image cache that stores loaded UIImage values in NSCache. This avoids redundant network requests when cells scroll off and back on screen.
Accessibility
Alt text and VoiceOver for placeholder and failure states
Pass a meaningful accessibility label to every image state. The loading placeholder should announce 'Loading image' and the failure fallback should describe what the image was supposed to show. Avoid leaving the default empty label, which causes VoiceOver to read out the URL string.
Use .accessibilityHidden(true) on decorative shimmer views so VoiceOver skips the loading animation. The success image receives the full descriptive label.
// Accessibility for each phase
case .empty:
Rectangle()
.fill(Color(.systemGray5))
.accessibilityLabel("Loading image")
case .failure:
AsyncImage(url: fallbackURL) { ... }
.accessibilityLabel(alt ?? "Image unavailable") Resources
Further reading and fallback.pics routes
The fallback.pics API provides dimension-matched SVG fallbacks that never 404 and cache for one year. Use the /api/v1/{w}x{h}/{bg}/{fg} route to match your app's color scheme exactly.
// Useful fallback.pics routes for iOS
// Product tile 200x200, brand purple background
https://fallback.pics/api/v1/200x200/7C3AED/FFFFFF?text=No+Photo
// Avatar 80x80 with initials
https://fallback.pics/api/v1/avatar/80?text=AB
// Hero banner 390x200 (iPhone width)
https://fallback.pics/api/v1/390x200/18181B/A1A1AA?text=Image+Unavailable
// Docs and API reference
https://fallback.pics/docs/
https://fallback.pics/placeholder-image-api/
https://fallback.pics/blog/expo-image-fallback/
https://fallback.pics/blog/react-native-image-fallback/ Key takeaways
What to standardize before shipping
- Use the AsyncImage phase closure to handle .empty, .success, and .failure states explicitly instead of relying on the default gray placeholder.
- In the .failure case, load a deterministic fallback URL from fallback.pics that matches your image's exact dimensions and color scheme.
- Cap retry attempts at two and require user intent (button tap) to avoid hammering a server that is genuinely down.
- Shimmer animations on the .empty phase communicate loading without consuming screen real estate for a spinner.
- Add explicit VoiceOver labels to every phase so screen reader users understand loading, failure, and success states.
Production fallback layer
Use fallback.pics anywhere an image URL is accepted.
Start with one deterministic URL and standardize fallback behavior across your design system.