Cached Image

AsyncImage drop-in with a memory + disk cache, so re-created views show remote images on the first frame instead of flashing the placeholder

Free
Free with a ShipSwift account.

Copies a short instruction with your private download link. Paste it into your agent: it downloads the whole skill (the source code, the setup steps and the mistakes we already made for you) and builds the screen.

Works with

+70

What it does

SWCachedImage has the same shape as AsyncImage(url:content:placeholder:) and is meant to replace it everywhere a remote image is shown: avatars, covers, feed photos, attachments.

AsyncImage keeps no in-memory cache. Whenever its view identity is re-created it starts over from the empty phase, so the placeholder shows for at least one frame before the image comes back. Identity is re-created more often than it looks: a ForEach whose ids change, a parent that re-renders under a 1 Hz countdown, a lazy stack scrolling an item back in, a card that flips. A row of avatars rebuilt once per second flickers between photo and placeholder, and users report it as a bug.

SWCachedImage keeps decoded images in memory and looks its URL up synchronously while the view is being built, so a re-created view is on the photo in its very first frame. Original bytes go to a dedicated disk cache, so a relaunch reads from disk instead of the network.

What you get

  • ✓241 lines of source code, ready to paste
  • ✓Features
  • ✓Source Code
  • ✓Usage
  • ✓Integration Checklist
  • ✓Pitfalls
componentdisplayimagecacheremoteAsyncImageavatarSwiftUI

Features

  • Same API shape as AsyncImage: url, optional scale, content and placeholder closures
  • Memory cache of decoded CGImages in an NSCache priced by byte size (200 MB default, purged by the system under memory pressure)
  • Synchronous lookup while the view is built: a cached image never shows the placeholder, even for one frame
  • Dedicated disk cache (URLCache in Caches/SWImageCache, 200 MB) with .returnCacheDataElseLoad: cached images load offline and never revalidate
  • One request per URL at a time, shared by every view that asks for it
  • 404 / 410 remembered for the session; network errors are not, so the next appearance retries
  • Background decoding through ImageIO, with EXIF orientation applied and the longest side capped at 2400 px
  • Cross-platform: renders through Image(decorative:scale:), so the same file builds for iOS and macOS
  • ImageRenderer can draw a cached remote image (share cards, receipts), where AsyncImage only ever renders its placeholder

Usage

// 1. Avatar with a monogram fallback (no URL, loading, or failed)
SWCachedImage(url: user.avatarURL) { image in
    image.resizable().scaledToFill()
} placeholder: {
    Circle()
        .fill(.blue.opacity(0.15))
        .overlay { Text(user.initial).font(.headline) }
}
.frame(width: 40, height: 40)
.clipShape(Circle())

// 2. Feed photo inside a fixed frame (overlay keeps scaledToFill from widening the layout)
Color.clear
    .frame(height: 220)
    .overlay {
        SWCachedImage(url: post.photoURL) { image in
            image.resizable().scaledToFill()
        } placeholder: {
            Color.secondary.opacity(0.15)
        }
    }
    .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))

// 3. Lists: give rows a stable id so they are not rebuilt on every render
ForEach(people, id: \.userID) { person in
    SWCachedImage(url: person.avatarURL) { $0.resizable().scaledToFill() } placeholder: { Color.gray }
        .frame(width: 32, height: 32)
        .clipShape(Circle())
}

// 4. Warm the cache as soon as data arrives, so the first appearance skips the placeholder too
for url in snapshot.avatarURLs {
    Task { _ = await SWImageCache.shared.image(for: url) }
}

Integration Checklist

  • Drop SWCachedImage.swift into the project and replace every AsyncImage that shows a remote image
  • Make image URLs immutable: a new upload gets a new key (for example a UUID file name). If your backend overwrites files in place, append a version to the URL (?v=updatedAt), otherwise the disk cache keeps serving the old image
  • Have the image server send Cache-Control: public, max-age=31536000, immutable, so other clients and CDNs cache it as well
  • macOS sandboxed apps need the Outgoing Connections (Client) entitlement
  • Tune memoryLimit, diskLimit and maxPixelSize in SWImageCache.init if your images are much larger or smaller than typical photos

Pitfalls

  • Unstable ids rebuild views: a view model built in a computed property with let id = UUID() gets new ids on every render, so ForEach tears down and re-creates its rows (local @State resets, AsyncImage restarts). The cache hides the flash, but use a stable business key (user id, name, record id) as the id anyway
  • Do not load images through URLSession.shared: its shared URLCache is small and shared with every API GET in the app. Images get their own session and cache directory here
  • Decode off the main thread: UIImage(data:) decodes lazily on the main thread during the first draw, which drops frames for large photos. Here ImageIO decodes inside Task.detached
  • nonisolated is not "background": with default MainActor isolation and approachable concurrency (NonisolatedNonsendingByDefault), a nonisolated async helper runs on the caller's executor, which is the main thread. That is why the decode step is a detached task
  • First load still shows the placeholder until the bytes arrive from the network or are read back from disk (tens of milliseconds, longer when a cold launch reads many at once). Warm the cache with SWImageCache.shared.image(for:) when the data arrives if the first appearance matters
  • Memory budget: a 1600 × 1600 photo is about 10 MB decoded. Images on screen stay alive through the view's state even after eviction; lower maxPixelSize for thumbnail-only screens