Skip to content

Repository files navigation

CachedAsyncImage

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.

Swift Platform License SPM

Author: Syed M Abdul Rehman
Contact: Jamisyed786@gmail.com


Table of Contents


Why We Built This

Every SwiftUI developer knows AsyncImage. It is simple, built-in, and works — until you ship to production.

The problem with native AsyncImage

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.

Our goal

Build a library that:

  1. Fixes the caching gap using Apple's own URLSession + URLCache
  2. Feels like AsyncImage — same mental model, minimal learning curve
  3. Stays lightweight — no heavy SDK, no Objective-C bridge
  4. Works out of the box — sensible defaults for 90% of apps
  5. Scales when needed — custom requests, presets, monitoring, protocol injection

CachedAsyncImage is the middle ground between bare AsyncImage and a full image pipeline.


What You Get

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

Real-world impact

  • 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 URLSession wrappers in every view
  • Smaller app — no need for a 2 MB image SDK when you only need caching

Installation

Swift Package Manager (recommended)

Xcode

  1. Open your project in Xcode
  2. Go to File → Add Package Dependencies…
  3. Paste the repository URL:
    https://github.com/Jamisyed/CachedAsyncImage.git
    
  4. Select version 1.0.0 or later
  5. 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 SwiftUI

Quick Start

Replace 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.


Complete Usage Guide

1. Basic image loading

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

2. Placeholder and error states

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).


3. Phase-based API (like AsyncImage)

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
}

4. Cache presets

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()
}

5. Custom cache configuration

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

6. Authenticated / signed URLs

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()
}

7. App-wide configuration

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.


8. Load monitoring and debugging

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()

9. Lists and feeds

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.


10. Advanced: custom loader injection

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

How It Works

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
  1. Request — URL is wrapped in a URLRequest with your cache policy
  2. FetchURLSession checks memory cache, then disk cache, then network
  3. Classify — Response source (memory, disk, network) is recorded
  4. Decode — Raw data becomes a SwiftUI Image
  5. 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.


Legacy Libraries vs CachedAsyncImage

What Kingfisher, Nuke, and SDWebImage do

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.

What CachedAsyncImage does

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

Side-by-side comparison

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

Pros and Cons

Pros

  • 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-Modified via URLCache
  • 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 monitoringImageLoadMonitor for cache hit rate debugging
  • Testable — inject custom ImageLoading and ImageLoadObserving via protocols
  • Pure Swift — modern concurrency, @Observable, Sendable configuration

Cons

  • SwiftUI only — no UIKit UIImageView helper 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

When to Use What

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)

API Reference

Views

// 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:)

Configuration

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(_:)

Environment

.imageCacheConfiguration(_:)  // Set app-wide cache defaults

Observation

ImageLoadMonitor()             // Track loads, hits, and hit rate
ImageLoadEvent                 // Individual load event record
ImageLoadSource                // .network, .memoryCache, .diskCache

Protocols

ImageLoading                   // Custom fetch logic
ImageCacheProviding            // Custom request building
ImageLoadObserving             // Custom event handling

Requirements

Minimum
iOS 17.0+
macOS 14.0+
Swift 5.9+
Xcode 15.0+

License

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.

About

A lightweight, pure-Swift library for HTTP-cached remote images in SwiftUI.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages