HTTP-cached remote images for SwiftUI — drop-in, lightweight, production-ready.
If you have ever scrolled a feed, watched images reload from the network, and wondered why AsyncImage does not cache like a browser — this library is for you.
Inspired by the WWDC 2027 AsyncImage caching improvements, CachedAsyncImage brings that same HTTP caching model to SwiftUI apps today — with zero setup, or full control when you need it.
Author: Syed M Abdul Rehman
Contact: Jamisyed786@gmail.com
- Why We Built This
- What You Get
- Installation
- Quick Start
- Complete Usage Guide
- How It Works
- Legacy Libraries vs CachedAsyncImage
- Pros and Cons
- When to Use What
- API Reference
- Requirements
- License
Every SwiftUI developer knows AsyncImage. It is simple, built-in, and works — until you ship to production.
In real apps, remote images are loaded constantly: profile avatars, product thumbnails, news feeds, chat attachments. Without proper HTTP caching:
| Problem | What happens in production |
|---|---|
| No HTTP cache respect | Cache-Control, ETag, and Last-Modified headers are ignored |
| Scroll-back re-downloads | User scrolls away and back — same image hits the network again |
| Wasted bandwidth | Users on cellular pay for data they already downloaded |
| Slow UI | Placeholders flash on every revisit instead of instant cache hits |
| No auth support | Signed CDN URLs and Bearer tokens require workarounds |
| No configuration | You cannot tune URLCache, timeout, or cache policy |
This is why teams reach for Kingfisher, Nuke, or SDWebImage — even when all they really need is reliable HTTP caching.
Build a library that:
- Fixes the caching gap using Apple's own
URLSession+URLCache - Feels like
AsyncImage— same mental model, minimal learning curve - Stays lightweight — no heavy SDK, no Objective-C bridge
- Works out of the box — sensible defaults for 90% of apps
- Scales when needed — custom requests, presets, monitoring, protocol injection
CachedAsyncImage is the middle ground between bare AsyncImage and a full image pipeline.
| Benefit | Details |
|---|---|
| Instant cache hits | Images served from memory or disk via standard HTTP caching |
| Zero configuration | Works with one line — CachedAsyncImage(url:) |
| AsyncImage-like API | If you know AsyncImage, you already know this |
| Auth-ready | Pass custom URLRequest with Bearer tokens or signed headers |
| Four cache presets | .default, .aggressive, .offlineFirst, .noCache |
| App-wide defaults | Set once with .imageCacheConfiguration() |
| Debug monitoring | Track cache hit rate with ImageLoadMonitor |
| Testable architecture | Swap loaders and observers via protocols |
| Tiny footprint | Pure Swift, no third-party dependencies |
- Faster feeds — scrolling back does not re-fetch images
- Lower bandwidth — cached responses skip the network
- Better UX — fewer loading spinners on repeat views
- Less code — no custom
URLSessionwrappers in every view - Smaller app — no need for a 2 MB image SDK when you only need caching
Xcode
- Open your project in Xcode
- Go to File → Add Package Dependencies…
- Paste the repository URL:
https://github.com/Jamisyed/CachedAsyncImage.git - Select version 1.0.0 or later
- Add the CachedAsyncImage library to your app target
Package.swift
dependencies: [
.package(url: "https://github.com/Jamisyed/CachedAsyncImage.git", from: "1.0.0"),
],
targets: [
.target(
name: "YourApp",
dependencies: ["CachedAsyncImage"]
),
]Then import it anywhere you need cached images:
import CachedAsyncImage
import SwiftUIReplace AsyncImage with CachedAsyncImage. That is it.
import CachedAsyncImage
import SwiftUI
struct ProfileView: View {
let avatarURL: URL
var body: some View {
CachedAsyncImage(url: avatarURL) { image in
image
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(Circle())
} placeholder: {
ProgressView()
}
}
}The image is downloaded once, cached according to HTTP headers, and served from cache on every subsequent load.
The simplest form — pass a URL, provide content and placeholder closures:
CachedAsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}Parameters:
| Parameter | Type | Description |
|---|---|---|
url |
URL? |
Remote image URL. nil shows placeholder. |
content |
(Image) -> Content |
View builder for the loaded image |
placeholder |
() -> Placeholder |
View builder shown while loading or on failure |
While loading, the placeholder is shown. On failure, the placeholder appears with a warning icon overlay:
CachedAsyncImage(url: imageURL) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
}For full control over error UI, use the phase-based API (see below).
PhaseCachedAsyncImage mirrors AsyncImage's phase pattern — handle .empty, .success, and .failure explicitly:
PhaseCachedAsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "photo.badge.exclamationmark")
.foregroundStyle(.secondary)
}
}Optional configuration and observer:
PhaseCachedAsyncImage(
url: imageURL,
configuration: .aggressive,
observer: monitor
) { phase in
// handle phases
}Four built-in presets cover the most common production scenarios:
| Preset | Cache Policy | Memory | Disk | Best for |
|---|---|---|---|---|
.default |
Protocol | 50 MB | 200 MB | Production — honors server Cache-Control headers |
.aggressive |
Cache else load | 100 MB | 500 MB | Feeds, catalogs — prefers cached data |
.offlineFirst |
Cache don't load | 75 MB | 300 MB | Offline mode — cache only, no network |
.noCache |
Reload | 50 MB | 200 MB | Debug — always fetches fresh (legacy behavior) |
Usage:
CachedAsyncImage(
url: imageURL,
configuration: .aggressive
) { image in
image.resizable()
} placeholder: {
ProgressView()
}Build your own configuration or tweak a preset:
// Custom from scratch
let config = ImageCacheConfiguration(
memoryCapacity: 80 * 1024 * 1024, // 80 MB memory
diskCapacity: 400 * 1024 * 1024, // 400 MB disk
diskPath: "MyApp.images",
cachePolicy: .returnCacheDataElseLoad,
requestTimeout: 15
)
// Or modify a preset
let tuned = ImageCacheConfiguration.aggressive
.withMemoryCapacityMB(150)
.withDiskCapacityMB(600)
.with(requestTimeout: 20)
CachedAsyncImage(url: imageURL, configuration: tuned) { image in
image.resizable()
} placeholder: {
ProgressView()
}Configuration properties:
| Property | Default | Description |
|---|---|---|
memoryCapacity |
50 MB | In-memory cache size |
diskCapacity |
200 MB | On-disk cache size |
diskPath |
"CachedAsyncImage.cache" |
Disk cache directory name |
cachePolicy |
.useProtocolCachePolicy |
URLRequest cache behavior |
requestTimeout |
30 seconds | Network request timeout |
Pass a custom URLRequest with auth headers for protected CDN URLs:
var request = URLRequest(url: signedImageURL)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
CachedAsyncImage(urlRequest: request) { image in
image.resizable()
} placeholder: {
ProgressView()
}With explicit cache configuration:
CachedAsyncImage(
urlRequest: request,
configuration: .default
) { image in
image.resizable()
} placeholder: {
ProgressView()
}Set cache defaults once at the root of your app. Every CachedAsyncImage in the hierarchy inherits it:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.imageCacheConfiguration(.aggressive)
}
}
}Individual views can still override with an explicit configuration: parameter.
Track cache performance during development with ImageLoadMonitor:
struct DebugFeedView: View {
@State private var monitor = ImageLoadMonitor()
var body: some View {
VStack {
// Stats bar
HStack {
Text("Network: \(monitor.totalNetworkLoads)")
Text("Cache hits: \(monitor.totalCacheHits)")
Text("Hit rate: \(Int(monitor.cacheHitRate * 100))%")
}
.font(.caption)
.padding()
// Image grid
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(imageURLs, id: \.self) { url in
CachedAsyncImage(url: url, observer: monitor) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 100, height: 100)
.clipped()
}
}
}
}
}Monitor properties:
| Property | Type | Description |
|---|---|---|
totalNetworkLoads |
Int |
Images fetched from network |
totalCacheHits |
Int |
Images served from cache |
cacheHitRate |
Double |
Hit rate (0.0 – 1.0) |
events |
[ImageLoadEvent] |
Detailed load history |
Reset stats anytime:
monitor.reset()CachedAsyncImage works naturally in List, LazyVGrid, and ScrollView. Cached images load instantly when cells reappear:
struct PhotoFeedView: View {
let photos: [URL]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)]) {
ForEach(photos, id: \.self) { url in
CachedAsyncImage(
url: url,
configuration: .aggressive
) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color.gray.opacity(0.15)
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
.imageCacheConfiguration(.aggressive)
}
}Scroll away, scroll back — images appear instantly from cache.
For testing or custom networking, inject your own ImageLoading implementation:
CachedAsyncImage(
urlRequest: request,
loader: myCustomLoader,
observer: myObserver
) { image in
image.resizable()
} placeholder: {
ProgressView()
}Protocols for extension:
| Protocol | Purpose |
|---|---|
ImageLoading |
Custom image fetch logic |
ImageCacheProviding |
Custom request/cache building |
ImageLoadObserving |
Custom load event handling |
CachedAsyncImage
│
├─ URLRequest (cache policy, auth headers, timeout)
│
├─ URLSession + URLCache (standard HTTP caching)
│ ├─ Memory cache (fast, volatile)
│ └─ Disk cache (persistent across launches)
│
├─ HTTPResponseCacheClassifier (memory / disk / network)
│
└─ Image decode → SwiftUI Image
- Request — URL is wrapped in a
URLRequestwith your cache policy - Fetch —
URLSessionchecks memory cache, then disk cache, then network - Classify — Response source (memory, disk, network) is recorded
- Decode — Raw data becomes a SwiftUI
Image - Display — Your content closure renders the result
This is the same caching model Apple's WWDC 2027 AsyncImage update introduces — available today as a standalone package.
These are full image pipelines — battle-tested, feature-rich, and widely used:
| Feature | Kingfisher / Nuke / SDWebImage |
|---|---|
| Memory + disk cache | Custom cache layers (not just URLCache) |
| Image processing | Resize, blur, round corners, color filters |
| Prefetching | Load images before they scroll into view |
| GIF / WebP / HEIC | Animated and modern format support |
| Request deduplication | Cancel in-flight requests, coalesce duplicates |
| UIKit + SwiftUI | Mature UIKit APIs with SwiftUI wrappers |
| Progress callbacks | Byte-level download progress |
| Placeholder blending | Crossfade transitions between states |
They solve everything about remote images. But for many apps, that is more than you need.
| CachedAsyncImage | |
|---|---|
| Core job | HTTP-cached remote images in SwiftUI |
| Caching | Native URLCache + URLSession (standard HTTP semantics) |
| API | Mirrors AsyncImage — drop-in feel |
| Configuration | Presets + custom memory/disk/policy/timeout |
| Auth | Custom URLRequest with headers |
| Architecture | SOLID — swap loaders/observers via protocols |
| Dependency size | Lightweight, pure Swift, no Obj-C bridge |
| AsyncImage (legacy) | Kingfisher / Nuke | CachedAsyncImage | |
|---|---|---|---|
| HTTP cache | Weak | Custom cache | URLCache (HTTP standard) |
| SwiftUI API | Built-in | Wrapper | Native-like |
| Setup | None | Medium | Zero config |
| Bundle size | 0 | Medium–Large | Small |
| GIF / WebP | No | Yes | No |
| Prefetch | No | Yes | No |
| Auth headers | No | Yes | Yes |
| Image filters | No | Yes | No (use SwiftUI modifiers) |
| Cache monitoring | No | Varies | Built-in |
- Zero config works — sensible defaults (50 MB memory, 200 MB disk, protocol caching)
- AsyncImage-like API — if you know SwiftUI, you are already productive
- Lightweight — no large SDK, no Objective-C bridge, no external dependencies
- Standard HTTP caching — honors
Cache-Control,ETag,Last-ModifiedviaURLCache - Four presets —
.default,.aggressive,.offlineFirst,.noCache - Auth-ready — Bearer tokens and signed CDN URLs via custom
URLRequest - App-wide config — set once with
.imageCacheConfiguration() - Built-in monitoring —
ImageLoadMonitorfor cache hit rate debugging - Testable — inject custom
ImageLoadingandImageLoadObservingvia protocols - Pure Swift — modern concurrency,
@Observable, Sendable configuration
- SwiftUI only — no UIKit
UIImageViewhelper out of the box - No image processing — no built-in resize, blur, or filters (use SwiftUI modifiers instead)
- No prefetch API — does not preload off-screen images before they appear
- No GIF / WebP — loads static images via
URLSession(JPEG, PNG, HEIC, etc.) - No download progress — no byte-level progress callbacks
- Less advanced deduplication — shared loader per config, not as sophisticated as Nuke
- Smaller ecosystem — fewer community resources compared to Kingfisher/Nuke
- iOS 17+ / macOS 14+ — no legacy platform support
| Your scenario | Use this |
|---|---|
| Remote images with HTTP caching | CachedAsyncImage |
| Minimal dependencies, SwiftUI app | CachedAsyncImage |
| Signed or authenticated CDN URLs | CachedAsyncImage |
| Offline-first image catalogs | CachedAsyncImage (.offlineFirst) |
| GIFs, WebP, or animated images | Kingfisher or Nuke |
| Aggressive prefetching in long feeds | Nuke or Kingfisher |
| Heavy image processing (blur, crop pipeline) | Kingfisher or SDWebImage |
UIKit-heavy app with UIImageView |
Kingfisher or SDWebImage |
iOS 27+ with native caching in AsyncImage |
Native AsyncImage (future) |
// Primary view — URL + content/placeholder
CachedAsyncImage(url:observer:content:placeholder:)
// With explicit configuration
CachedAsyncImage(url:configuration:observer:content:placeholder:)
// Custom URLRequest (auth headers)
CachedAsyncImage(urlRequest:configuration:observer:content:placeholder:)
// Full injection (custom loader)
CachedAsyncImage(urlRequest:loader:observer:content:placeholder:)
// Phase-based (AsyncImage-style)
PhaseCachedAsyncImage(url:configuration:observer:content:)ImageCacheConfiguration.default // Protocol cache policy
ImageCacheConfiguration.aggressive // Prefer cached data
ImageCacheConfiguration.offlineFirst // Cache only, no network
ImageCacheConfiguration.noCache // Always reload
// Custom
ImageCacheConfiguration(
memoryCapacity:diskCapacity:diskPath:cachePolicy:requestTimeout:
)
// Modifiers
config.with(memoryCapacity:diskCapacity:diskPath:cachePolicy:requestTimeout:)
config.withMemoryCapacityMB(_:)
config.withDiskCapacityMB(_:).imageCacheConfiguration(_:) // Set app-wide cache defaultsImageLoadMonitor() // Track loads, hits, and hit rate
ImageLoadEvent // Individual load event record
ImageLoadSource // .network, .memoryCache, .diskCacheImageLoading // Custom fetch logic
ImageCacheProviding // Custom request building
ImageLoadObserving // Custom event handling| Minimum | |
|---|---|
| iOS | 17.0+ |
| macOS | 14.0+ |
| Swift | 5.9+ |
| Xcode | 15.0+ |
MIT — see LICENSE for details.
Built with care for SwiftUI developers everywhere.
If CachedAsyncImage saves you time or bandwidth, consider giving it a ⭐ on GitHub.