I recently received a request to build an image-loading subsystem for a real app. On paper, the request sounded familiar: load remote images quickly, keep scrolling smooth, handle retries, and avoid wasting network work. In practice, this is exactly the kind of problem that starts simple and becomes expensive when product usage grows.
This project, AsyncImagePipeline, is the precursor I built before shipping the production version. It is intentionally demonstrative, but every design decision is aimed at production constraints: correctness under interleaving, controlled concurrency under load, clear ownership boundaries, and Swift 6 concurrency safety.
The goal of this article is not to show an AsyncImage alternative. It is to show how to design a maintainable concurrency subsystem that a team can trust.
The Real Problem Behind "Load Some Images"
The request was not "fetch bytes and show pixels."
The real problem looked like this:
- the same image can be requested by multiple callers at nearly the same time
- network and decode work has non-trivial cost
- uncontrolled fan-out can overload resources
- one caller may cancel while others still need the same in-flight result
- failed requests must be retryable without stale failure state poisoning the system
- UI state must remain responsive and isolated on
MainActor - the whole system must be correct under Swift 6 strict concurrency checks
This is where many implementations drift into accidental complexity: ad-hoc caches, implicit shared state, inconsistent cancellation behavior, and diagnostics that get fixed with suppression instead of architecture.
The Paths I Considered (and Why I Rejected Most of Them)
Before settling on the final architecture, I tested a few approaches.
1) Direct Fetch from View Model Calls
The first instinct is to let each UI event start its own request and rely on URL caching.
Why it fails in production:
- duplicate caller overlap still does duplicate decode and coordination work
- ownership is unclear (where does dedup logic live?)
- cancellation semantics become inconsistent per call site
2) Cache-First, Everything Else Implicit
Another common approach is "cache if possible, otherwise fetch."
Why it is incomplete:
- it solves repeated requests after completion, not duplicate overlap while work is still in-flight
- it does not address bounded concurrency, cancellation policy, or retry correctness
3) Centralized Pipeline with Isolated Responsibilities
This is the one that scaled conceptually and operationally.
Why it works:
- each mutable domain has one owner
- in-flight task sharing becomes explicit and testable
- logs and metrics reflect real behavior instead of assumptions
- Swift 6 diagnostics reinforce the design instead of fighting it
Final Architecture
The system is layered by ownership, not by file count.
GalleryModel (@MainActor)
->
AsyncImagePipeline (public facade)
->
ImageCache actor + DownloadCoordinator actor + NetworkLoader actor
-> decode/transform concurrent computeComponent Responsibilities
| Component | Isolation | Owns | Why it exists |
|---|---|---|---|
GalleryModel | @MainActor | UI state, user intent | Keep presentation updates safe and predictable |
AsyncImagePipeline | actor | orchestration and public API | Single integration point for app features |
ImageCache | actor | decoded image storage | Avoid repeated decode work |
DownloadCoordinator | actor | in-flight task dictionary | Deduplicate overlapping requests |
NetworkLoader | actor | network calls and counters | Isolate I/O and observability |
Core Concurrency Decision: Share In-Flight Work
The most important pattern in this project is task sharing for duplicate requests.
Conceptually:
enum Entry {
case inProgress(Task<DecodedImage, Error>)
case ready(DecodedImage)
}When request B asks for a URL that request A is already loading, B joins A's in-flight task instead of spawning another one.
That gives three concrete wins:
- less duplicate network and decode work
- consistent result across waiters
- a single coordination point for cancellation and failure semantics
Proof Snippet: Orchestration Path
if let cachedImage = await cache.read(url: url) {
return PipelineFetch(url: url, source: .cacheHit, image: cachedImage, duration: duration)
}
let (task, isNewWork) = await coordinator.retrieveOrCreate(url: url) { [weak self] in
do {
guard let self = self else { throw PipelineError.networkError("Pipeline deallocated") }
return try await self.loader.fetch(url: url)
} catch {
if let self = self {
await self.coordinator.evictFailed(url: url)
}
throw error
}
}
let image = try await task.value
await cache.insert(image)
await coordinator.markReady(url: url, image: image)Bounded Concurrency Is a Product Decision
A lot of sample code optimizes for maximum parallelism. Production systems optimize for predictable throughput under resource limits.
For batch prefetch, the pipeline uses bounded child-task fan-out (for example, limit 2) instead of launching everything at once.
This policy matters for bandwidth, memory pressure, CPU decode spikes, and overall UI smoothness.
Proof Snippet: Bounded Submission
await withTaskGroup(of: PipelineFetch.self) { group in
var urlIterator = urls.makeIterator()
var submitted = 0
while submitted < maxConcurrent, let url = urlIterator.next() {
group.addTask { await self._fetch(url: url) }
submitted += 1
}
for await result in group {
results[result.url] = result
if let nextURL = urlIterator.next() {
group.addTask { await self._fetch(url: nextURL) }
}
}
}Cancellation and Failure Semantics Must Be Deliberate
Two areas are often under-specified.
Cooperative Cancellation
Cancellation is a signal, not forceful termination. In shared in-flight work, canceling one waiter should not automatically invalidate useful work for another waiter.
let fetchTaskA = Task { await pipeline.fetch(url: url) }
let fetchTaskB = Task { await pipeline.fetch(url: url) }
fetchTaskA.cancel()
let resultB = await fetchTaskB.valueCaller A cancels its wait. Caller B can still receive the shared result.
Failure Eviction and Retry
When an in-flight request fails, its entry must be evicted. If failed state is retained, retries can incorrectly reuse stale failure outcomes.
nonisolated func evictFailed(url: URL) async {
await _evictFailed(url: url)
}
private func _evictFailed(url: URL) {
inFlight.removeValue(forKey: url)
}Retries are explicit fresh work after eviction, which preserves correct retry semantics.
Network and Decode: Real Data, Real Cost Centers
This project intentionally uses real URLs (fixed picsum.photos IDs), actual downloaded bytes, and decode validation.
Why this matters:
- latency is observable, not fabricated
- payload size is measurable
- decode/transform cost is concrete
- logs and metrics become evidence, not placeholders
The loader also includes deterministic additional latency increments to make request interleaving behavior easier to observe during demos.
private enum LatencyPolicy {
static let perRequestIncrementSeconds: Double = 0.5
}
let addedLatency = nextAddedLatencySeconds()
if addedLatency > 0 {
let nanos = UInt64(addedLatency * 1_000_000_000)
try await Task.sleep(nanoseconds: nanos)
}
let (data, response) = try await URLSession.shared.data(from: url)Decode validation is kept as separate nonisolated compute:
nonisolated
func decodeImage(_ data: Data, url: URL) throws -> DecodedImage {
guard !data.isEmpty else {
throw PipelineError.decodeError("No image data received for \(url.lastPathComponent)")
}
guard UIImage(data: data) != nil else {
throw PipelineError.decodeError("Failed to decode image bytes for \(url.lastPathComponent)")
}
let checksum = data.reduce(0) { $0 &+ UInt32($1) }
return DecodedImage(url: url, imageData: data, byteCount: data.count, checksum: Int(checksum))
}Project-Level Swift Concurrency Setup
Before writing pipeline code, I configured concurrency behavior explicitly at the project level.
SWIFT_VERSION = 6
SWIFT_STRICT_CONCURRENCY = complete
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YESHow I applied this in practice:
- I set
SWIFT_VERSIONusing5or6values only. - I treated compiler version and language mode as separate decisions during setup and migration.
- I used default
MainActorisolation for app-layer UI code, and explicit actor isolation for non-UI concurrency boundaries.
Setting this upfront avoided misleading diagnostics and made architecture decisions easier to validate.
What Swift 6 Concurrency Clarified
Swift 6 strict checking was not friction for this project. It was design feedback.
The most useful enforced clarifications were:
- explicit actor ownership of shared mutable state
Sendable-safe values crossing boundaries- clear distinction between
@MainActorUI state and non-UI concurrent work
This is exactly the kind of pressure you want before production traffic provides harsher feedback.
Observability: Why This Is More Than a Demo
The app is organized into four scenarios, each mapped to a specific production risk:
- Duplicate request sharing
- Bounded prefetch
- Failure eviction and retry
- Cancellation under deduplication
Each scenario exposes logs and metrics that make behavior auditable:
- new underlying starts
- shared in-flight hits
- total network requests
- max concurrent network requests
This is important because architecture claims should be verifiable.
Proof Snippet: Metrics Aggregation
let (totalRequests, maxConcurrent, _) = await loader.metrics()
let newWorkStarts = await coordinator.newWorkCount
let sharedInFlightHits = await coordinator.sharedInFlightCount
let cacheSize = await cache.size()
let inFlightCount = await coordinator.inFlightCount()
return PipelineMetrics(
totalFetches: totalRequests,
newWorkStarts: newWorkStarts,
sharedInFlightHits: sharedInFlightHits,
cacheHits: cacheSize,
failures: 0,
maxConcurrentRequests: maxConcurrent,
currentInFlightCount: inFlightCount
)Practical Tradeoffs and Next Hardening Steps
This precursor is intentionally small, but the core architecture is directly portable.
If I were moving this into the target production app next, I would harden in this order:
- add focused tests around dedup, bounded fan-out, cancellation semantics, and retry correctness
- add cache policy controls (TTL/size strategy) based on product usage patterns
- add richer instrumentation hooks for on-device diagnostics and telemetry integration
- tune prefetch limits per screen and network conditions
The key point: these are hardening steps on top of stable boundaries, not a redesign.
Final Takeaways
- Concurrency design starts with ownership and isolation, not threads.
- Actors are valuable because they make mutable state boundaries explicit.
- Shared in-flight work is often better than duplicate work.
- Cancellation and retry behavior must be specified as semantics, not left implicit.
- Swift 6 strict concurrency is a design ally when building production systems.
This project started as a demonstrative implementation, but it was built as a production precursor. That distinction matters.
A prototype proves something can work once. A pipeline like this is designed so it keeps working when usage, concurrency, and product complexity increase.
Appendix: Mental Model Used Throughout
task
↓
executor
↓
isolation domainThe terms that guided implementation:
Using this vocabulary consistently helped keep both the code and the reasoning precise.