In modern mobile app development, it is almost impossible to find an app that lives 100% “offline.” Whether you are building an e-commerce platform, a social network, a news app, or a simple catalog, sooner or later you will face one of the most common—and sometimes frustrating—requirements of iOS development: loading images from the internet.
If you come from the old world of UIKit, you will remember that UIImageView had no native way to load a URL. We had to deal with URLSession, manually dispatch tasks to the Main Thread, or rely on massive third-party libraries to do something that should be trivial.
With the arrival of SwiftUI, Apple promised to simplify our lives. But have they really succeeded? Is AsyncImage the definitive solution, or should we still use external libraries?
In this massive tutorial, we are going to break down everything you need to know to handle and load remote images in Xcode with SwiftUI. You won’t just learn how to display a photo; you will learn about caching, memory management, error handling,and how to create a fluid user experience.
Part 1: The Native Solution (iOS 15+) – AsyncImage
With the release of iOS 15, Apple introduced AsyncImage. This is the standard and recommended way for most simple use cases.
1.1 Basic Usage
The simplest implementation requires just one line of code. AsyncImage takes care of:
- Making the asynchronous network request.
- Downloading the data.
- Decoding the image.
- Displaying it on the screen.
import SwiftUI
struct BasicImageView: View {
// Example URL (make sure to use HTTPS)
let imageUrl = URL(string: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e")
var body: some View {
VStack {
Text("My Remote Image")
.font(.headline)
// Basic load
AsyncImage(url: imageUrl)
}
}
}The problem with this approach: If you run this code, you’ll notice something strange. If the image is larger than the iPhone screen, it will overflow the edges. If you try to add the .resizable() modifier, Xcode will throw an error.
Why? Because AsyncImage is a wrapper, not the image itself, until it loads.
1.2 Customization and Resizing
To manipulate the image (make it resizable, change the aspect ratio, crop it), we need to use the initializer that provides a closure.
AsyncImage(url: imageUrl) { image in
image
.resizable()
.scaledToFill() // Or .scaledToFit()
} placeholder: {
// What is shown while loading
ProgressView()
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 20))Code Analysis:
- image: This is the actual
Imageobject once downloaded. This is where we apply.resizable(). - placeholder: This is a temporary view. Using a
ProgressView(the native spinner) is a UX best practice. - External modifiers:
.frame()and.clipShape()are applied to theAsyncImagecontainer, ensuring that both the placeholder and the final image respect the size constraints.
Part 2: State Management (Loading, Success, and Error)
In the real world, connections fail. URLs break. Servers go down. A professional app cannot just leave a blank space if something goes wrong.
For this, AsyncImage offers us granular control through AsyncImagePhase.
2.1 Implementing Phases
This initializer gives us access to an enum that tells us exactly what is happening.
struct AdvancedImageView: View {
let url = URL(string: "https://example.com/image-that-might-fail.jpg")
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
// Phase 1: Loading
ZStack {
Color.gray.opacity(0.1)
ProgressView()
}
case .success(let image):
// Phase 2: Success
image
.resizable()
.scaledToFit()
.transition(.opacity.animation(.easeIn)) // Smooth animation
case .failure(let error):
// Phase 3: Error
VStack {
Image(systemName: "wifi.slash")
.font(.largeTitle)
.foregroundColor(.red)
Text("Error loading")
.font(.caption)
.foregroundColor(.secondary)
// Optional: Print error to console for debug
let _ = print(error.localizedDescription)
}
@unknown default:
// Future Apple cases
EmptyView()
}
}
.frame(height: 250)
.background(Color(.systemGray6))
.cornerRadius(12)
}
}Advantages of this method:
- Visual Feedback: The user always knows what is happening.
- Error Handling: You can show a “retry” icon or a default placeholder if the image fails.
- Transitions: Note how we added
.transition(.opacity). This prevents the image from “popping in” abruptly, making the app feel more elegant.
Part 3: The Great Debate – To Cache or Not to Cache?
Here is where we enter real “Software Engineering” territory.
AsyncImage uses iOS’s default URL caching system (URLCache). This means:
- If the server sends the correct HTTP headers (
Cache-Control), iOS will temporarily save the image. - However, this cache is volatile. If you close the app and reopen it, or if the system needs RAM, the image will be downloaded again.
- In a list (List/LazyVStack) with hundreds of images,
AsyncImagemight re-download images you saw 10 seconds ago if you scroll up and down quickly.
3.1 When is AsyncImage enough?
- Prototypes.
- Apps with few images.
- Images that change very frequently and shouldn’t be saved.
3.2 When do you need something more powerful?
- Social networks (infinite feeds).
- Apps that must work with poor connections.
- When you need to optimize CPU and battery performance.
Part 4: The Professional Solution (Kingfisher)
If you need persistent cache (on disk) and high performance, the iOS community has a gold standard: Kingfisher. Although it is an external library, it is so common that it is practically considered part of the ecosystem.
4.1 Installation
In Xcode:
- Go to
File>Add Packages... - Paste the URL:
https://github.com/onevcat/Kingfisher - Select your project and click “Add Package”.
4.2 Using Kingfisher in SwiftUI (KFImage)
Kingfisher simplifies everything we saw in Part 2 and adds automatic disk and RAM caching.
import SwiftUI
import Kingfisher
struct KingfisherExampleView: View {
let url = URL(string: "https://images.unsplash.com/photo-1550258987-190a2d41a8ba")
var body: some View {
KFImage(url)
.placeholder {
// What is shown while loading
ProgressView()
}
.onFailure { error in
print("Load failed: \(error)")
}
.resizable() // Kingfisher allows putting this directly
.loadDiskFileSynchronously() // Load optimization
.cacheMemoryOnly() // Optional: Cache configuration
.fade(duration: 0.25) // Integrated transition
.aspectRatio(contentMode: .fill)
.frame(width: 300, height: 300)
.clipShape(Circle())
}
}4.3 Why use Kingfisher?
- Downsampling: If you download a 4K image but display it in a 50x50px icon, Kingfisher can resize it in memory to avoid wasting RAM. This prevents your app from crashing due to memory pressure.
- Smart Caching: It automatically manages the expiration of old files so as not to fill up the user’s iPhone.
- Prefetching: You can download images before they appear on the screen for an instant experience.
Part 5: Creating Your Own ImageLoader (Advanced)
To finish this tutorial, we’re going to see how to do it “by hand.” Why? Because in a technical interview you might be asked not to use libraries, or maybe you need very specific control that AsyncImage doesn’t provide.
We will use the ObservableObject and Combine pattern.
5.1 The ViewModel (ImageLoader)
import Combine
class ImageLoader: ObservableObject {
@Published var image: UIImage? = nil
@Published var isLoading: Bool = false
private var cancellables = Set<AnyCancellable>()
private static let cache = NSCache<NSString, UIImage>() // Simple memory cache
func load(from urlString: String) {
guard let url = URL(string: urlString) else { return }
// 1. Check Cache
if let cachedImage = ImageLoader.cache.object(forKey: urlString as NSString) {
self.image = cachedImage
return
}
self.isLoading = true
// 2. Network Request
URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.receive(on: DispatchQueue.main) // Important: Return to main thread
.sink { [weak self] loadedImage in
self?.isLoading = false
if let validImage = loadedImage {
// 3. Save to Cache
ImageLoader.cache.setObject(validImage, forKey: urlString as NSString)
self?.image = validImage
}
}
.store(in: &cancellables)
}
}5.2 The Custom View
struct CustomRemoteImage: View {
@StateObject private var loader = ImageLoader()
let url: String
var body: some View {
Group {
if loader.isLoading {
ProgressView()
} else if let image = loader.image {
Image(uiImage: image)
.resizable()
} else {
Image(systemName: "photo") // Error placeholder
}
}
.onAppear {
loader.load(from: url)
}
}
}This code gives you a deep understanding of how asynchronous data works in SwiftUI. Although it is more verbose than AsyncImage, it gives you total control over cache logic and the network session.
Conclusion
We have come a long way. To summarize, here is my final recommendation as a senior developer:
- Use
AsyncImageif you are just starting out, if your app is simple, or if you only need to display a few static images (like a user profile or a detail header). It is native, lightweight, and requires no dependencies. - Use
Kingfisher(or SDWebImage) if you are building a complex commercial app with long lists (LazyVStack), grids, or if the loading experience must be instant and persistent. - Implement your own Loader only if you have very strict security requirements (such as authentication in image headers) or for educational purposes.
Mastering image loading is the first step to mastering iOS performance. A poorly optimized image can ruin even the most beautiful user experience.
If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.