Lab 1.D — Async image fetcher with actor cache

Goal: Build a small image-fetching pipeline that demonstrates the four headline Swift concurrency features end-to-end: async/await for the network call, URLSession’s async API, an actor-based cache, and TaskGroup for parallel fetches. The result: a Fetcher type your future SwiftUI views can use to load images concurrently without races.

Time budget: 90 minutes.

Prerequisites: Ch 1.7, Ch 1.8, Ch 1.9, Ch 1.10. And Lab 1.B — you’ll re-use the SwiftPM workflow.

What you’ll build

let fetcher = Fetcher()

// One-off fetch (cached after first call)
let data = try await fetcher.image(from: url)

// Parallel batch — kicks off N concurrent requests, gathers results
let images = try await fetcher.images(from: urls)

Under the hood:

  • Network calls use URLSession.shared.data(from:).
  • An actor ImageCache deduplicates concurrent fetches for the same URL (no thundering-herd).
  • A TaskGroup parallelizes batch requests.
  • A typed FetchError enum distinguishes network from decode from HTTP failures.

Step 1 — Scaffold

mkdir asyncfetcher && cd asyncfetcher
swift package init --type library --name AsyncFetcher

In Package.swift, target macOS 13 (for URLSession’s async API):

let package = Package(
    name: "AsyncFetcher",
    platforms: [.macOS(.v13), .iOS(.v16)],
    products: [.library(name: "AsyncFetcher", targets: ["AsyncFetcher"])],
    targets: [
        .target(name: "AsyncFetcher"),
        .testTarget(name: "AsyncFetcherTests", dependencies: ["AsyncFetcher"]),
    ]
)

Step 2 — Error type

Sources/AsyncFetcher/FetchError.swift:

import Foundation

public enum FetchError: Error, Equatable {
    case invalidURL
    case http(status: Int)
    case transport(message: String)
    case cancelled
}

Step 3 — The actor cache

Sources/AsyncFetcher/ImageCache.swift:

import Foundation

/// Caches data by URL AND deduplicates concurrent in-flight requests.
/// Two callers asking for the same URL at the same time share one fetch.
actor ImageCache {

    private enum Entry {
        case ready(Data)
        case inFlight(Task<Data, Error>)
    }

    private var store: [URL: Entry] = [:]

    /// Returns cached data if present; otherwise runs the closure (only once),
    /// caches the result, and returns it. Concurrent callers share the same Task.
    func data(for url: URL, fetch: @Sendable @escaping () async throws -> Data) async throws -> Data {
        if let entry = store[url] {
            switch entry {
            case .ready(let d):      return d
            case .inFlight(let t):   return try await t.value
            }
        }

        let task = Task<Data, Error> { try await fetch() }
        store[url] = .inFlight(task)

        do {
            let data = try await task.value
            store[url] = .ready(data)
            return data
        } catch {
            store[url] = nil          // failure shouldn't be cached
            throw error
        }
    }

    func clear() { store.removeAll() }
}

Read this carefully — this is the lab’s most important concept. The actor’s data(for:fetch:) method does three things atomically:

  1. Checks the cache.
  2. If empty, creates ONE Task to do the fetch.
  3. Returns that task’s value — so 100 concurrent callers all await the same task.

This is how you avoid “thundering herd” — 100 callers, 1 network request.

Step 4 — The fetcher

Sources/AsyncFetcher/Fetcher.swift:

import Foundation

public final class Fetcher: Sendable {
    private let session: URLSession
    private let cache = ImageCache()

    public init(session: URLSession = .shared) {
        self.session = session
    }

    public func image(from url: URL) async throws -> Data {
        try await cache.data(for: url) { [session] in
            try await Self.download(url: url, session: session)
        }
    }

    public func images(from urls: [URL]) async throws -> [URL: Data] {
        try await withThrowingTaskGroup(of: (URL, Data).self) { group in
            for url in urls {
                group.addTask { [self] in
                    let data = try await self.image(from: url)
                    return (url, data)
                }
            }
            var result: [URL: Data] = [:]
            for try await (url, data) in group {
                result[url] = data
            }
            return result
        }
    }

    private static func download(url: URL, session: URLSession) async throws -> Data {
        do {
            let (data, response) = try await session.data(from: url)
            guard let http = response as? HTTPURLResponse else {
                throw FetchError.transport(message: "non-HTTP response")
            }
            guard (200..<300).contains(http.statusCode) else {
                throw FetchError.http(status: http.statusCode)
            }
            return data
        } catch is CancellationError {
            throw FetchError.cancelled
        } catch let e as FetchError {
            throw e
        } catch {
            throw FetchError.transport(message: error.localizedDescription)
        }
    }
}

Notice:

  • Fetcher is a final class conforming to Sendable because it has no mutable state (all state lives in the actor).
  • images(from:) uses withThrowingTaskGroup for parallelism — N URLs become N concurrent requests, bounded by the implicit task group.
  • Error mapping happens at the network boundary; everything below the API surface throws FetchError.

Step 5 — Tests

Tests/AsyncFetcherTests/AsyncFetcherTests.swift:

import XCTest
@testable import AsyncFetcher

final class AsyncFetcherTests: XCTestCase {

    func test_fetches_real_image() async throws {
        let f = Fetcher()
        let url = URL(string: "https://httpbin.org/image/png")!
        let data = try await f.image(from: url)
        XCTAssertGreaterThan(data.count, 0)
    }

    func test_404_throws_http_error() async {
        let f = Fetcher()
        let url = URL(string: "https://httpbin.org/status/404")!
        do {
            _ = try await f.image(from: url)
            XCTFail("expected throw")
        } catch let FetchError.http(status) {
            XCTAssertEqual(status, 404)
        } catch {
            XCTFail("expected .http, got \(error)")
        }
    }

    func test_parallel_fetch_returns_all() async throws {
        let f = Fetcher()
        let urls = [
            URL(string: "https://httpbin.org/image/png")!,
            URL(string: "https://httpbin.org/image/jpeg")!,
        ]
        let results = try await f.images(from: urls)
        XCTAssertEqual(results.count, 2)
    }

    func test_concurrent_callers_share_one_fetch() async throws {
        // Two simultaneous fetches for the same URL should result in one
        // network call. (Proving this rigorously requires a mock URLProtocol;
        // here we just assert the data matches and there's no crash.)
        let f = Fetcher()
        let url = URL(string: "https://httpbin.org/image/png")!
        async let a = f.image(from: url)
        async let b = f.image(from: url)
        let (da, db) = try await (a, b)
        XCTAssertEqual(da, db)
    }
}

Run them: swift test. (These tests hit the network — for CI, you’d swap in URLProtocol mocks. That’s the stretch goal.)

Step 6 — Try it from a quick driver

Add an executableTarget demo to Package.swift, or open a playground that imports the library:

import AsyncFetcher

let urls = (1...10).map { URL(string: "https://picsum.photos/200?random=\($0)")! }
let f = Fetcher()
let start = Date()
let results = try await f.images(from: urls)
let elapsed = Date().timeIntervalSince(start)
print("Fetched \(results.count) images in \(String(format: "%.2f", elapsed))s")

Sequential await would take 10× the per-image latency. The TaskGroup should make this dramatically faster.

Done when

  • swift test is green.
  • The demo fetches 10 images in roughly the same time as fetching 1.
  • You can explain (out loud) why the cache is an actor and not a struct.
  • You can explain (out loud) why Fetcher is a final class: Sendable and not just a struct.
  • You used withThrowingTaskGroup correctly — both the addTask side and the for try await collection side.

Stretch goals

  • Bounded concurrency. Use a custom executor or a Semaphore to cap concurrent in-flight requests at, say, 5. Real-world image grids do this to avoid overwhelming the server.
  • Cancellation. When the calling task is cancelled, the in-flight URLSession task should also cancel. Verify with Task.checkCancellation().
  • Disk cache layer. Add a second cache that writes to ~/Library/Caches/AsyncFetcher/ so cached images survive app restarts.
  • Mock URLProtocol for tests. Replace the live HTTP calls in tests with a deterministic mock so CI doesn’t depend on httpbin.
  • Memory pressure. Bound the in-memory cache size (e.g., max 50 MB or 200 entries) using an LRU strategy.

Real-world context

This is roughly the architecture Apple’s own AsyncImage (in SwiftUI) and Kingfisher/Nuke (popular third-party image libraries) use internally: an actor-isolated cache, structured concurrency for batch loads, typed errors at the boundary. You haven’t built a toy — you’ve built the foundational layer of a production image pipeline.

Build out the stretch goals over a weekend and you can credibly say in an interview: “I’ve built an async image fetcher with an actor-based dedup cache and bounded parallelism. Let me sketch it.”


You’ve finished Phase 1. ← back to Memory management | next phase coming soon.