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.

01

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.

02

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
03

Final Architecture

The system is layered by ownership, not by file count.

Architecture map
GalleryModel (@MainActor)
   ->
AsyncImagePipeline (public facade)
   ->
ImageCache actor + DownloadCoordinator actor + NetworkLoader actor
   -> decode/transform concurrent compute

Component Responsibilities

ComponentIsolationOwnsWhy it exists
GalleryModel@MainActorUI state, user intentKeep presentation updates safe and predictable
AsyncImagePipelineactororchestration and public APISingle integration point for app features
ImageCacheactordecoded image storageAvoid repeated decode work
DownloadCoordinatoractorin-flight task dictionaryDeduplicate overlapping requests
NetworkLoaderactornetwork calls and countersIsolate I/O and observability
04

Core Concurrency Decision: Share In-Flight Work

The most important pattern in this project is task sharing for duplicate requests.

Conceptually:

Swift
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

Swift
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)
05

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

Swift
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) }
        }
    }
}
06

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.

Swift
let fetchTaskA = Task { await pipeline.fetch(url: url) }
let fetchTaskB = Task { await pipeline.fetch(url: url) }

fetchTaskA.cancel()
let resultB = await fetchTaskB.value

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

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

07

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.

Swift
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:

Swift
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))
}
08

Project-Level Swift Concurrency Setup

Before writing pipeline code, I configured concurrency behavior explicitly at the project level.

Build settings
SWIFT_VERSION = 6
SWIFT_STRICT_CONCURRENCY = complete
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YES

How I applied this in practice:

  • I set SWIFT_VERSION using 5 or 6 values only.
  • I treated compiler version and language mode as separate decisions during setup and migration.
  • I used default MainActor isolation 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.

09

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 @MainActor UI state and non-UI concurrent work

This is exactly the kind of pressure you want before production traffic provides harsher feedback.

10

Observability: Why This Is More Than a Demo

The app is organized into four scenarios, each mapped to a specific production risk:

  1. Duplicate request sharing
  2. Bounded prefetch
  3. Failure eviction and retry
  4. 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

Swift
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
)
11

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:

  1. add focused tests around dedup, bounded fan-out, cancellation semantics, and retry correctness
  2. add cache policy controls (TTL/size strategy) based on product usage patterns
  3. add richer instrumentation hooks for on-device diagnostics and telemetry integration
  4. tune prefetch limits per screen and network conditions

The key point: these are hardening steps on top of stable boundaries, not a redesign.

12

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.

A

Appendix: Mental Model Used Throughout

Mental model
task
  ↓
executor
  ↓
isolation domain

The terms that guided implementation:

taskstructured concurrencyin-flight shared workactor isolationsuspension pointreentrancycooperative cancellationbounded concurrencySendable boundaryMainActor UI boundary

Using this vocabulary consistently helped keep both the code and the reasoning precise.