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 ImageCachededuplicates concurrent fetches for the same URL (no thundering-herd). - A
TaskGroupparallelizes batch requests. - A typed
FetchErrorenum 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:
- Checks the cache.
- If empty, creates ONE
Taskto do the fetch. - Returns that task’s
value— so 100 concurrent callers allawaitthe 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:
Fetcheris afinal classconforming toSendablebecause it has no mutable state (all state lives in the actor).images(from:)useswithThrowingTaskGroupfor 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 testis 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
Fetcheris afinal class: Sendableand not just a struct. -
You used
withThrowingTaskGroupcorrectly — both theaddTaskside and thefor try awaitcollection side.
Stretch goals
- Bounded concurrency. Use a custom executor or a
Semaphoreto 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
URLSessiontask should also cancel. Verify withTask.checkCancellation(). - Disk cache layer. Add a second cache that writes to
~/Library/Caches/AsyncFetcher/so cached images survive app restarts. - Mock
URLProtocolfor 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.