You put the image cache behind an actor. Strict concurrency checking is happy. Then you open a feed and notice the same image URL downloading twice.
The tempting conclusion is that actors are not doing their job. Usually, they are. Your dictionary is protected from simultaneous access; the operation you built around that dictionary still has a gap.
That gap is an await between checking the cache and filling it. A second caller can walk through the same gap while the first download is waiting on the network. Nothing corrupts memory. You simply pay for two requests.
Let’s fix that with an actor that caches downloaded Data, shares work already in progress, and refuses to let an obsolete request overwrite a newer one. The tricky parts are cancellation and invalidation, so we’ll make those policies explicit rather than hide them behind a short snippet.
1. An actor protects state, not a whole transaction
Here is a plausible first implementation:
import Foundation
actor NaiveDataCache {
private var values: [URL: Data] = [:]
func data(for url: URL) async throws -> Data {
if let cached = values[url] {
return cached
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode) else {
throw URLError(.badServerResponse)
}
values[url] = data
return data
}
}
Caller A sees a cache miss and starts downloading. Its method suspends. Caller B enters the actor, sees the same miss, and starts another download. Each dictionary access is isolated, but the check-download-store sequence is not indivisible.
Apple’s actor session demonstrates this exact image-downloading problem. Actor reentrancy allows other work to proceed during suspension; your assumptions about mutable state must survive that interleaving.
One important nuance: await marks a possible suspension, not a promise that execution will pause. Write code that is correct either way. Also, actors do not guarantee FIFO processing of incoming work. The actor proposal explicitly distinguishes their scheduling from a serial dispatch queue.
Rechecking the dictionary after the download can avoid overwriting an existing value. It cannot recover the bandwidth already spent on a duplicate request. For that, the dictionary needs to represent work in progress.
2. Store the request before suspending
For each URL, our actor stores either a running task or completed bytes. Both states retain the identity of the request that produced them.
The following implementation uses an injectable fetch operation. The optional registration callback is a synchronous instrumentation hook; production callers can leave it at its default. We will use it to observe request registration in tests without exposing the actor’s dictionary.
import Foundation
actor SharedDataCache {
typealias Fetch = @Sendable (URL) async throws -> Data
private struct Flight {
let id: UUID
let task: Task<Data, Error>
}
private enum Entry {
case running(Flight)
case ready(id: UUID, data: Data)
var id: UUID {
switch self {
case .running(let flight): flight.id
case .ready(let id, _): id
}
}
}
private var entries: [URL: Entry] = [:]
private let fetch: Fetch
private let onRegistered: @Sendable (URL) -> Void
init(
fetch: @escaping Fetch,
onRegistered: @escaping @Sendable (URL) -> Void = { _ in }
) {
self.fetch = fetch
self.onRegistered = onRegistered
}
func data(for url: URL) async throws -> Data {
try Task.checkCancellation()
let flight: Flight
switch entries[url] {
case .ready(_, let data):
return data
case .running(let existing):
flight = existing
case nil:
let operation = fetch
flight = Flight(
id: UUID(),
task: Task { try await operation(url) }
)
entries[url] = .running(flight)
}
// Synchronous: registration is observable before the first await.
onRegistered(url)
let data: Data
do {
data = try await flight.task.value
} catch {
if case .running(let current) = entries[url],
current.id == flight.id {
entries[url] = nil
}
throw error
}
// Invalidation or replacement makes this result obsolete.
guard entries[url]?.id == flight.id else {
throw CancellationError()
}
entries[url] = .ready(id: flight.id, data: data)
// Cancelling this caller does not cancel other callers' work.
try Task.checkCancellation()
return data
}
func invalidate(_ url: URL) {
if case .running(let flight) = entries.removeValue(forKey: url) {
flight.task.cancel()
}
}
}
The important transition is synchronous: a miss becomes .running before this method awaits anything. A later caller finds that task and awaits the same result instead of creating another task.
The first waiter to resume stores .ready. Other waiters may resume afterward, which is why the completed entry keeps the same identity. They can still recognize their result as current.
This is request coalescing, sometimes called single-flight loading. It is not a global networking queue: different URLs get independent tasks, and the actor remains available while they wait.

We return Data rather than move UIImage instances across isolation boundaries. Decode images in an appropriate part of your application, and separately cache decoded representations if necessary. Our Swift 6 concurrency-safe type guide explains how these boundary choices relate to Sendable.
3. Give shared work a clear owner
A task created with Task { ... } is unstructured. Keeping its handle lets the cache share and explicitly cancel it; it does not give the lifetime guarantees of an async let child.
Our policy is deliberately simple:
- An already-cancelled caller cannot start or join a request.
- Cancelling one waiter does not cancel the shared fetch.
- Once the fetch finishes, a cancelled waiter throws instead of returning bytes.
- Explicit invalidation removes the entry and requests cancellation of its fetch.
There is a tradeoff: a cancelled waiter can remain suspended on task.value until the shared fetch completes. The post-result cancellation check prevents delivery, but does not provide immediate escape. Use request timeouts, and do not describe this API as promptly cancellable per caller.
If your product requires immediate individual cancellation, maintain a separate continuation for each waiter, remove cancelled waiters safely, and decide whether the last waiter leaving should cancel the producer. That is more than a cancellation handler around the shared task. Calling flight.task.cancel() whenever any waiter cancels would make one disappearing cell break other visible cells.
Swift cancellation is cooperative; cancellation flags do not forcibly terminate arbitrary operations. The Swift concurrency guide describes explicit cancellation checks and unstructured task responsibilities. Our async cleanup and cancellation guide covers the resource-release side of that decision.
Also keep onRegistered lightweight. It executes synchronously inside actor isolation, so blocking logging or expensive instrumentation would delay every cache operation. It must not attempt synchronous reentry into the cache.
4. Invalidation needs more than cancelling a task
Imagine this sequence:
- A download starts for a URL.
- A refresh invalidates the URL and cancels that task.
- A replacement download starts for the same URL.
- The original operation finishes anyway.
That last step is possible with a custom producer that ignores cancellation, or with work that completed near the cancellation request. Without an identity check, the old completion can populate the cache with stale bytes. An unconditional cleanup in the old task’s error path can be just as bad: it could remove the replacement entry.
Our success path accepts a result only while its identity remains current. The error path removes only its own running entry. An invalidated caller receives CancellationError if obsolete work succeeds; if its underlying fetch throws, that error is propagated instead. Neither path damages the replacement.
This is an application-level policy, not magic supplied by actor isolation. It also means invalidation revokes outstanding results, rather than merely removing completed cache entries. Name and document that behavior for your callers.
For a screen-level version of this ownership rule, debouncing SwiftUI search and preventing stale results invalidates each request synchronously when the user edits the query.
For authenticated content, a URL alone is often an insufficient key. Include the account, relevant request headers, representation, or authorization scope in a dedicated Sendable key. On logout, remove all account-scoped entries and cancel their tasks. Otherwise perfectly synchronized code can still return the wrong user’s content.
5. Connect a real fetcher without hiding failures
Inject networking at construction rather than hard-coding it inside the actor:
import Foundation
let imageBytes = SharedDataCache { url in
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}
Failed requests are removed, so a later call can retry. Successful bytes remain cached until invalidated. Neither behavior implements an automatic retry loop; add bounded retries inside the fetcher only when the operation and server semantics justify them.
The cache shown here has no eviction policy, byte limit, expiry, or maximum number of distinct in-flight URLs. That keeps the concurrency mechanism visible, but it is not a finished image-loading library. Add those limits before using it for an unbounded feed. Coalescing reduces repeated work for one key; it does not stop a hundred different keys from downloading simultaneously.
URLSession also has HTTP caching behavior. Your application cache serves a different purpose: coordinating in-progress requests and managing reuse under your own policy. Respect server cache directives and sensitive-content requirements rather than assuming every successful response belongs in memory forever.
Do not use this actor as a place to do synchronous image decompression. Moving code behind await does not itself make expensive work cheap or keep a screen responsive. See why async code can still freeze the UI for that separate problem.
6. Test overlap with gates, not lucky timing
A test that launches two tasks and sleeps for 50 milliseconds is testing the scheduler as much as the cache. Hold the fetch open explicitly, observe both registrations, then release the response.
This test double records each fetch and waits for the test to resolve it. It intentionally ignores cancellation so we can exercise obsolete completions too.
import Foundation
import Testing
actor GatedFetcher {
nonisolated let starts: AsyncStream<Int>
private let signals: AsyncStream<Int>.Continuation
private var pending: [Int: CheckedContinuation<Data, Error>] = [:]
private var count = 0
init() {
let pair = AsyncStream<Int>.makeStream()
starts = pair.stream
signals = pair.continuation
}
func load(_ url: URL) async throws -> Data {
count += 1
let id = count
return try await withCheckedThrowingContinuation { continuation in
pending[id] = continuation
signals.yield(id)
}
}
func resolve(_ id: Int, with result: Result<Data, Error>) {
pending.removeValue(forKey: id)?.resume(with: result)
}
func callCount() -> Int { count }
}
@Test(.timeLimit(.minutes(1)))
func overlappingRequestsShareOneFetch() async throws {
let url = try #require(URL(string: "https://example.com/image.jpg"))
let fetcher = GatedFetcher()
let registered = AsyncStream<Void>.makeStream()
let cache = SharedDataCache(
fetch: { try await fetcher.load($0) },
onRegistered: { _ in registered.continuation.yield(()) }
)
var registrations = registered.stream.makeAsyncIterator()
var starts = fetcher.starts.makeAsyncIterator()
let first = Task { try await cache.data(for: url) }
let second = Task { try await cache.data(for: url) }
_ = await registrations.next()
_ = await registrations.next()
let id = try #require(await starts.next())
#expect(await fetcher.callCount() == 1)
let bytes = Data([1, 2, 3])
await fetcher.resolve(id, with: .success(bytes))
#expect(try await first.value == bytes)
#expect(try await second.value == bytes)
#expect(try await cache.data(for: url) == bytes)
#expect(await fetcher.callCount() == 1)
}
@Test(.timeLimit(.minutes(1)))
func staleCompletionCannotReplaceNewRequest() async throws {
let url = try #require(URL(string: "https://example.com/image.jpg"))
let fetcher = GatedFetcher()
let cache = SharedDataCache(fetch: { try await fetcher.load($0) })
var starts = fetcher.starts.makeAsyncIterator()
let old = Task { try await cache.data(for: url) }
let oldID = try #require(await starts.next())
await cache.invalidate(url)
let replacement = Task { try await cache.data(for: url) }
let newID = try #require(await starts.next())
await fetcher.resolve(oldID, with: .success(Data([0])))
await #expect(throws: CancellationError.self) { try await old.value }
let fresh = Data([9])
await fetcher.resolve(newID, with: .success(fresh))
#expect(try await replacement.value == fresh)
#expect(try await cache.data(for: url) == fresh)
#expect(await fetcher.callCount() == 2)
}
The registration events are emitted before awaiting the shared result. Observing two of them proves that both callers have reached the running-request path while the fake fetch is still blocked. The fake’s start event guarantees its continuation exists before we resolve it.
The second test deliberately completes obsolete work before the replacement. That checks the identity guard, not just whether cancellation happens to work on a particular network stack. Add tests for failure followed by retry, a cancelled waiter with another caller still active, and obsolete failures that must not remove replacements.
These examples were compiled and the two tests passed in Swift 6 language mode with Apple Swift 6.4. The underlying actor and task mechanisms date to Swift 5.5; the examples use newer convenience APIs such as AsyncStream.makeStream and Swift Testing. For broader suite design, see our practical Swift Testing guide.
7. Conclusion: model the state you actually have
The original cache had two states: absent and downloaded. The application actually had three: absent, downloading, and downloaded. Once the actor stores all three, sharing work becomes straightforward.
The remaining decisions belong to your product: whether cancellation should stop shared work, what invalidation revokes, how long content stays cached, and what identifies a request. Actors give those decisions a safe home. They do not make them for you.
Key Takeaways
- Store an in-flight task before the first suspension so overlapping callers share it.
- Treat every suspension as a boundary where actor state may have changed.
- Use request identity on both completion and cleanup paths.
- Document waiter cancellation separately from producer cancellation.
- Test controlled interleavings with explicit gates, then add bounded caching and networking policies.
