A minimal alternative to Kingfisher and Nuke. A SwiftUI-native async image loader with memory + disk caching, downsampling, request coalescing and cancel-on-disappear — in ~1,000 lines with zero dependencies.
GlimpseImage(url: post.imageURL) { phase in
switch phase {
case .empty: ProgressView()
case .success(let img): img.resizable().scaledToFill()
case .failure: Image(systemName: "photo")
@unknown default: EmptyView()
}
}SwiftUI's AsyncImage has no decoded-image cache, no request coalescing, and no control over decode
size — it decodes every image at full resolution, so a list of 3000×2000 photos costs ~24 MB of RAM
each. Kingfisher and Nuke solve all of that, and a great deal more besides: image processors,
animated GIF, progressive JPEG, UIKit view extensions, custom transports.
Glimpse is the middle ground. It does the four things that actually make image loading feel fast, and stops:
- Caches decoded images in memory, keyed by URL and display size.
- Caches original bytes on disk, so a cold launch doesn't re-download.
- Downsamples at decode time to the size the view actually occupies.
- Coalesces duplicate requests and cancels loads when views disappear.
It's a drop-in replacement for AsyncImage — same initializers, same AsyncImagePhase — so
adopting it is a rename.
| Glimpse | AsyncImage |
Kingfisher / Nuke | |
|---|---|---|---|
| Decoded-image memory cache | ✅ | ❌ | ✅ |
| Disk cache | ✅ own store, size + TTL budget | HTTP URLCache only, server-header dependent |
✅ |
| Downsampling to view size | ✅ | ❌ | ✅ |
| Request coalescing | ✅ | ❌ | ✅ |
| Prefetching | ✅ | ❌ | ✅ |
| Cancel on disappear | ✅ | ✅ | ✅ |
| Animated GIF / APNG | ❌ | ❌ | ✅ |
| Progressive JPEG | ❌ | ❌ | ✅ |
| Image processors / filters | ❌ | ❌ | ✅ |
| UIKit / AppKit view support | ❌ | ❌ | ✅ |
| Dependencies | 0 | — | 0 |
| Source size | 681 lines of code across 11 files | — | Substantially larger |
If you need the ❌ rows, use Nuke or Kingfisher. They're excellent, and Glimpse is not trying to replace them — it's trying to be the smaller thing you reach for when you don't need them.
// Package.swift
dependencies: [
.package(url: "https://github.com/dayaki/Glimpse.git", from: "1.0.0")
]Or in Xcode: File → Add Package Dependencies… and paste the URL.
import GlimpseGlimpseImage(url: url)
GlimpseImage(url: url)
.placeholder { Color.gray.opacity(0.2) }The same shape as AsyncImage:
GlimpseImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 64, height: 64)
.clipShape(.rect(cornerRadius: 8))GlimpseImage(url: url, transaction: Transaction(animation: .easeIn(duration: 0.2))) { phase in
switch phase {
case .empty: ShimmerView()
case .success(let img): img.resizable().scaledToFill()
case .failure(let err): ErrorView(error: err)
@unknown default: EmptyView()
}
}By default Glimpse measures the view and decodes to fit it. You can be explicit, or opt out:
// Decode to a known size (in points; multiplied by the display scale internally).
GlimpseImage(url: url)
.glimpseDownsampling(.fixed(CGSize(width: 64, height: 64)))
// Decode at natural size, like AsyncImage.
GlimpseImage(url: url)
.glimpseDownsampling(.none)
// Set it once for a whole screen.
MyFeedView()
.glimpseDownsampling(.automatic)Automatic sizing works best when the image's size doesn't depend on the image — a .frame, a grid
cell, a list row. If the layout is entirely image-driven, Glimpse measures the placeholder first and
re-decodes (from cache, not the network) once layout settles.
struct Feed: View {
let posts: [Post]
var body: some View {
List(posts) { post in
Row(post: post)
.task {
// Warm the next few rows while this one is on screen.
let upcoming = posts.next(3, after: post).map(\.imageURL)
await ImagePipeline.shared.prefetch(upcoming)
}
}
}
}cancelPrefetch(_:) stops prefetches that scrolled out of range. It's safe to call for a URL a
visible view is also loading — the shared download only dies once nothing is waiting on it.
let pipeline = ImagePipeline(
configuration: GlimpseConfiguration(
memoryLimit: 64 * 1024 * 1024, // decoded images
diskLimit: 250 * 1024 * 1024, // original bytes
ttl: 60 * 60 * 24 * 30 // 30 days
)
)
WindowGroup {
ContentView()
.glimpsePipeline(pipeline)
}Defaults: memory min(150 MB, ¼ of physical RAM), disk 150 MB, TTL 7 days, cache directory
Library/Caches/<bundle-id>.glimpse.
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let image = try await ImagePipeline.shared.image(
for: ImageRequest(urlRequest: request, targetSize: CGSize(width: 200, height: 200))
)await ImagePipeline.shared.removeAll() // both layers
await ImagePipeline.shared.removeAllFromMemory() // keep the bytes on diskThe pipeline takes a URLSessionConfiguration, so you can stub the network without Glimpse exposing
a transport protocol:
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MyStubProtocol.self]
let pipeline = ImagePipeline(
configuration: GlimpseConfiguration(
diskCacheDirectory: temporaryDirectory,
urlSessionConfiguration: configuration
)
)
MyView().glimpsePipeline(pipeline)GlimpseImage (@MainActor)
│ .task(id:) — starts on appear, cancels on disappear
▼
ImagePipeline (actor)
├─ 1. data(for:) memory-miss → DiskCache → URLSession keyed by URL
└─ 2. image(for:) MemoryCache → ImageDecoder.downsample keyed by URL + pixel size + scale
Two stages, two keys. Bytes are cached by URL alone; decoded images by URL and size. So one download backs every size an image is ever shown at, and changing the display size re-decodes without re-downloading.
The size/fetch race. A view doesn't know its pixel size until the first layout pass, but the download doesn't care about size. So Glimpse starts fetching immediately and decodes once the bytes and the size are both in hand. Re-decodes only ever fire after a successful load, which means layout settling can never cancel an in-flight download.
Coalescing with refcounts. Duplicate requests share one Task, which carries a subscriber
count. One view scrolling away decrements it; the download is only cancelled when it reaches zero.
Five cells asking for the same avatar produce one request, and four of them going offscreen doesn't
strand the fifth.
Downsampling. CGImageSourceCreateThumbnailAtIndex with ShouldCacheImmediately, so the decode
happens off the main thread instead of lazily at draw time. Target sizes are quantized to 64px
buckets so 199px and 201px slots share a cache entry, and Glimpse never upscales past an image's
natural size.
Disk cache. No index file: SHA256 of the URL is the filename, modification date doubles as the
LRU timestamp, and fileSize feeds the budget — the filesystem is the only state. Sweeps drop
expired files, then delete oldest-first down to 80% of the budget so writes don't trigger a sweep
every time. Reads only touch the timestamp if it's over an hour stale, so browsing a warm cache
doesn't cause a write per image.
Concurrency. Everything is Swift 6 strict-concurrency clean in the .v6 language mode. Disk I/O
and image decoding run in nonisolated async functions, which execute on the global executor — the
pipeline actor is held for bookkeeping only, never for the length of a read or a decode. URLCache
is disabled on the session, since caching every byte twice would be wasteful.
Nothing in this README is a number you have to take on faith:
swift build # warning-free under Swift 6 strict concurrency
swift test # 36 tests, including coalescing + cancellation semantics
xcodebuild -scheme Glimpse -destination 'generic/platform=iOS Simulator' build
# The size claim
find Sources -name '*.swift' -exec cat {} + | grep -vE '^\s*(//|$)' | wc -lThe tests assert the behaviour the design is sold on, not just the happy path: that eight concurrent requests produce exactly one network call, that cancelling one subscriber leaves the download alive while cancelling all of them kills it, that changing decode size re-decodes without re-downloading, that a cold pipeline reuses bytes from disk, and that HTTP failures aren't cached.
For memory, measure it in your own app — Instruments → Allocations, the same list twice, once with
AsyncImage and once with GlimpseImage. The saving is a function of how much bigger your source
images are than their slots, so a synthetic number here would tell you nothing useful.
Honest limitations, not a roadmap:
- No animated GIF / APNG or progressive JPEG. Static images only.
- No image processors. No blur, rounding, or filter pipeline — use SwiftUI modifiers.
- No UIKit / AppKit views.
DecodedImage.platformImagegives you aUIImage/NSImageif you need to bridge, but there's noimageView.setImage(url:). - No retries or negative caching. A failure is reported and not remembered.
- No HTTP revalidation. Freshness is Glimpse's TTL, not
ETag/Cache-Control. Cache-bust with a versioned URL if your images mutate in place. - Prefetch politeness is priority-based. Prefetches run at
.utilitybut share the connection pool, so a very large prefetch batch can still compete with visible loads.
iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+ · Swift 6.0+ / Xcode 16+
MIT — see LICENSE.