Why Waiting Shouldn’t Cost a Thread
Every Scroll Spawned Sixty Threads
Prologue: The View That Looked Fine
The thumbnails in a list screen were loaded by a view that looked like this. SwiftUI’s AsyncImage has no cache, so we had written our own.
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL?
let content: (Image) -> Content
let placeholder: () -> Placeholder
@State private var uiImage: UIImage?
var body: some View {
Group {
if let uiImage { content(Image(uiImage: uiImage)) }
else { placeholder() }
}
.onAppear(perform: load)
}
private func load() {
guard let url else { return }
if let cached = ImageMemoryCache.shared.image(for: url) {
uiImage = cached
return
}
DispatchQueue.global().async {
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return }
ImageMemoryCache.shared.set(image, for: url)
DispatchQueue.main.async {
uiImage = image
}
}
}
}When the view appears, it checks the cache. On a miss, it fetches on a global queue, decodes, and assigns the result to @State on main. Simple, and easy to read.
This isn’t a contrived bad example. “AsyncImage has no cache → I'll write one → Data(contentsOf:) is a one-liner" is a path many of us have walked.
The symptoms show up under fast scrolling. The scroll stutters, memory climbs, and when the list refreshes while requests are still in flight, images land in rows they don’t belong to. Pause the app in Xcode mid-scroll and the thread list runs past seventy.
To put numbers on it, I rebuilt this view in a reproduction project: a 200-row list of thumbnails behind a local server that answers in 250 ms, flicked from top to bottom and back by code so every run is the same. Every number in this piece comes from that project, measured in the iOS Simulator on an M1 Pro. They are relative numbers, not device numbers — but the shape of the problem doesn’t change.
For a long time this code didn’t look like a problem. With a short list and small images, nothing happens. It surfaced when the list grew to a few hundred items and users started flicking through them.
1. What This Code Is Actually Doing
1.1 Two Kinds of Work
Everything an app does falls into roughly two kinds.
- Work the CPU does itself: image decoding, JSON parsing, encryption, arithmetic.
- Work the CPU hands off and waits for: network, disk, a Bluetooth reply, a timer, another process.
load() mixes the two. Data(contentsOf:) is waiting for the network. Decoding the image is work the CPU does.
The trouble is in the first half. Data(contentsOf:) is synchronous. It doesn't return until the response arrives, and for that whole stretch the thread stands there doing nothing — but still occupied. Sixty rows pass through the screen, onAppear fires sixty times, and sixty threads stand around, each waiting for its own response.
row 1 appears → thread 1 blocks in Data(contentsOf:)
row 2 appears → thread 2 blocks
...
row 60 appears → thread 60 blocks
sixty threads, zero workThat’s where the question came from.
If we’re only waiting, why does it need a thread at all?
Holding one thread is nothing. The problem is what happens when they pile up.
1.2 Why Sixty Is Different From One
- Every thread reserves a 512 KB stack plus kernel bookkeeping. Sixty idle threads reserve about 30 MB of address space, most of it never touched — a few megabytes resident. That alone isn’t the memory problem; the real one comes later.
- GCD doesn’t create threads forever. Somewhere around 64 it stops, and new work can’t even start — it waits in the queue. That was why images kept arriving long after scrolling had stopped: 9.7 seconds after, in the reproduction.
- The stutter came from the same place, one step removed. When SwiftUI draws an image, it decodes it on a GCD global queue and waits for the result inside the Core Animation commit. With all 64 GCD threads parked in
Data(contentsOf:), that decode couldn't start, so the main thread sat indispatch_group_waituntil a download finished and gave a thread back. In the reproduction that was 80% of the main thread's time while scrolling back up, in stalls of one to two seconds. The thread explosion didn't just waste threads; it starved the framework's own work. - One step further is deadlock. If a sleeping thread is waiting for a signal that has to come from another GCD thread, and every thread is asleep, there’s no one left to send it. This code didn’t go that far — the kernel’s network stack does the waking — but put a semaphore into the same shape and it will.
There’s one more thing hiding in this code, and it only surfaced later. UIImage(data:) doesn't decode the image. It reads the header and defers the real decode to the first time the image is drawn — at commit time, with the main thread waiting on it. So the "background decoding" the global queue was supposedly doing wasn't happening there at all. It was happening at draw time, on the same starved pool, with the main thread blocked behind it.
1.3 The Wrong-Row Image Is a Different Bug
The image landing in the wrong row is a separate problem. onAppear has no matching cancellation. When a row's url changes while a request is in flight — a refresh that swaps thumbnails, a row that is handed a new item — the request made for the previous url keeps running, comes back, and overwrites uiImage. GCD has no structure for cancelling work once it has been dispatched. In the reproduction, with the list refreshing every 400 ms during a scroll, 186 of 393 completed images (47%) landed in a row whose url had already moved on.
2. Why GCD Led Us Here
Whoever wrote this code didn’t make a mistake. Under GCD, this was the natural choice.
2.1 A Closure Can’t Pause
GCD’s unit of work is a closure. A closure runs from start to finish, and there is no way to stop in the middle of a function and pick up later. If you want “check cache → fetch → decode → store → display” to read as one flow, you have two options.
Callbacks — the thread stays free, but the code splits.
URLSession.shared.dataTask(with: url) { data, _, _ in
guard let data else { return }
DispatchQueue.global().async {
guard let image = UIImage(data: data) else { return }
ImageMemoryCache.shared.set(image, for: url)
DispatchQueue.main.async {
uiImage = image
}
}
}.resume()Receiving, decoding, and displaying end up three layers deep. Add error handling and cancellation and it goes deeper still.
Synchronous calls — the code reads in order, but the thread is held.
DispatchQueue.global().async {
let data = try? Data(contentsOf: url) // the thread stops here
let image = UIImage(data: data)
DispatchQueue.main.async { ... }
}Given the choice, the second one is easier. And GCD lets you get away with it: it quietly spins up one more thread and moves on. For a single row, it works perfectly. That is exactly why the pattern spreads through a codebase without a single warning.
2.2 It Was Already Everywhere
Search a mature codebase for Data(contentsOf:) next to DispatchQueue.global().async and you rarely find just one. Each one is harmless on its own. Until a few dozen of them run at once on the same screen.
3. What Swift Concurrency Actually Changed
Swift Concurrency changed exactly one thing. A function can now stop in the middle and resume later.
func loadImage(from url: URL) async throws -> UIImage? {
let (data, _) = try await URLSession.shared.data(from: url) // the function folds here
return await UIImage(data: data)?.byPreparingForDisplay() // and resumes here when the response arrives
}At each await, the compiler cuts the function into two pieces. When the first piece finishes, the thread is handed back and the local variables are kept on the heap. When the response comes in, whichever thread is free picks up the second piece. The code reads like the synchronous version; the behavior is the callback version's. Nothing is held while waiting.
3.1 The Cooperative Pool, in Three Terms
Once waiting stops holding threads, you don’t need many of them. So Swift Concurrency keeps exactly as many threads as there are cores. That’s the cooperative thread pool. Three terms are enough to read the experiments below.
- Job: a slice of execution between two
awaits. The thing that actually runs on a thread. - Executor: the queue jobs wait in. One global, plus one per actor instance.
- Pool thread: fixed at the core count. Pulls a job from the queue, runs it, and the moment that job hits
await, drops it and picks up the next.
3.2 The One Rule
One rule comes attached. Never block a pool thread. With six threads, blocking one on Data(contentsOf:) throws away a sixth of your throughput. Block all six and the entire async world stops.
This rule is why swapping DispatchQueue.global().async for Task { } in the prologue's code, and changing nothing else, is more dangerous than it was under GCD. GCD's ceiling was 64. The pool's is the core count — eight on the Mac these experiments ran on, six on a recent iPhone. Six rows scroll past and not just image loading but every piece of async code in the app freezes. The cliff moved much closer.
Get Basin’s stories in your inbox
Join Medium for free to get updates from this writer.
The deeper mechanics are in WWDC21’s Swift concurrency: Behind the scenes.
4. Does It Hold Up? Three Experiments
I understood it on paper. I wanted to see the numbers. Three experiments, built with -O and run on an M1 Pro (8 cores: 6 performance, 2 efficiency). Each time is the median of seven runs.
4.1 CPU-bound: Summing a Large Array
Summing fifty million Doubles — serially, with DispatchQueue.concurrentPerform, and with withTaskGroup.
Method Time Speedup
---------------------------- -------- ------------------
Serial for 47.2 ms 1×
concurrentPerform, 10 chunks 10.0 ms 4.7×
TaskGroup, 10 chunks 10.0 ms 4.7×
TaskGroup, 100 chunks 7.8 ms 6.0×
TaskGroup, 1,000 chunks 8.1 ms 5.8×
TaskGroup, 100,000 chunks 103.6 ms slower than serialEight cores, six of them performance cores, and the best split lands at 6.0× — about what the hardware allows. Ten equal chunks on eight unequal cores lose a little to the tail; a hundred chunks balance better. Between GCD and Swift there is no difference at all. For CPU work, the tool is not the answer. The core count is the ceiling, and no tool lifts it.
Task creation does have a cost, but it shows up far later than I expected. At 10,000 chunks the TaskGroup starts losing ground, and at 100,000 it is slower than the serial loop — while concurrentPerform, which doesn't allocate a task per iteration, still finishes in 15 ms. Whichever way we write the decoding step, it costs the same.
4.2 I/O-bound: N Things Waiting
N tasks each waiting 200 ms, launched together. Measured the threads actually used and the total time. A scaled-down version of N rows requesting images at once. GCD waits with a synchronous call; Swift waits with await.
// GCD + synchronous wait (same shape as the prologue)
DispatchQueue.global().async(group: group) {
Thread.sleep(forTimeInterval: 0.2) // stands in for Data(contentsOf:)
record(pthread_self())
}
// Swift Concurrency
group.addTask {
try? await Task.sleep(for: .milliseconds(200))
return "\(pthread_self())"
}N GCD threads GCD time Swift threads Swift time
------------------------------- ----------- -------- ------------- ----------
10 10 210 ms 9 207 ms
64 64 210 ms 6 208 ms
100 64 419 ms 5 202 ms
1000 64 3317 ms 9 212 ms
1000, pool forced to one thread — — 1 211 msGCD grows threads with the number of waiters, hits the ceiling at 64, and from there splits the work into two rounds, then sixteen. Swift never goes past the core count — five to nine threads here, and those are busy running the tiny first halves of a thousand tasks, not waiting. The time doesn’t move. It let go of the thread while it waited.
The difference lives entirely in the waiting, not in the work. And the prologue’s Data(contentsOf:) is precisely that waiting.
There is a tool for catching code that blocks the pool. Set LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 in the scheme's environment and the pool shrinks to a single thread, so one blocking call anywhere stops everything on the spot. It also makes the point more bluntly than any table — that last row is it. With the pool forced down to one thread, a thousand concurrent 200 ms waits still finished in 211 ms. One thread. Waiting needs no thread at all.
4.3 When the Two Are Mixed
Eight CPU jobs of three seconds each — decoding, say — one per core, are running when a network response comes in. How late is the response handled?
Condition Response delay
---------------------------------------------- --------------
CPU jobs and the response at the same priority 2950 ms
Response one priority level above the CPU jobs 2–17 ms
CPU jobs call Task.yield() every ~5 ms ~10 ms
CPU jobs call Task.yield() every ~50 ms ~50 msAt equal priority, the pool is one line. The job that handles the response sits in the queue until a decode finishes, and the screen that was awaiting a result wakes up three seconds late. The app doesn't die; it just goes quiet.
Give the response a higher priority than the decodes — .userInitiated over .medium, or .medium over .utility; only the relative order matters — and the runtime hands it a thread of its own. The eight decodes kept running on their eight threads, and the response resumed on a ninth, in single-digit milliseconds. The cooperative pool is sized per priority level, not once for the whole app.
The other route is to make the heavy work yield. With Task.yield() every 5 ms the response gets in within 10 ms; every 50 ms, within 50. The delay is whatever interval you choose.
The cooperative pool doesn’t reduce the total amount of CPU work. Keeping light work from getting stuck behind heavy work is still the developer’s job — by priority, or by yielding.
5. The Fix
Two things needed fixing: waiting that held a thread, and the previous request surviving row reuse. Three options were on the table.
Approach When Trade-off
------------------------------------------------------------------------ ------------------------------------------- -------------------------------------------------------------------
URLSession.data(from:) + SwiftUI .task(id:) the network API offers async Cleanest. Cancellation is tied structurally to the view's lifecycle
Wrap a callback API in withCheckedContinuation an SDK or legacy API offers only callbacks Cancellation needs separate handling
Isolate on a GCD queue outside the pool, then bridge with a continuation a synchronous blocking API can't be changed A GCD thread absorbs the blocking. Many calls, many threadsURLSession had an async API and the deployment target was iOS 16. The first option.
// Before
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL?
let content: (Image) -> Content
let placeholder: () -> Placeholder
@State private var uiImage: UIImage?
var body: some View {
Group {
if let uiImage { content(Image(uiImage: uiImage)) }
else { placeholder() }
}
.onAppear(perform: load) // no cancellation
}
private func load() {
guard let url else { return }
if let cached = ImageMemoryCache.shared.image(for: url) {
uiImage = cached
return
}
DispatchQueue.global().async {
guard let data = try? Data(contentsOf: url), // thread held while waiting
let image = UIImage(data: data) else { return }
ImageMemoryCache.shared.set(image, for: url)
DispatchQueue.main.async {
uiImage = image // lands in the wrong row after reuse
}
}
}
}
// After
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL?
let ttlSeconds: TimeInterval
let content: (Image) -> Content
let placeholder: () -> Placeholder
@State private var uiImage: UIImage?
var body: some View {
Group {
if let uiImage { content(Image(uiImage: uiImage)) }
else { placeholder() }
}
.task(id: url) { await load() } // cancelled when the view goes away, restarted when url changes
}
private func load() async {
uiImage = nil // don't let the previous row's image linger
guard let url else { return }
if let cached = await ImageCache.shared.image(for: url, ttl: ttlSeconds) {
uiImage = cached
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url) // the thread is released while we wait
guard !Task.isCancelled,
let image = await UIImage(data: data)?.byPreparingForDisplay() else { return } // decode finishes on a pool thread
await ImageCache.shared.set(image, for: url, ttl: ttlSeconds, cost: data.count)
uiImage = image
} catch {}
}
}5.1 What Changed
Data(contentsOf:)→await URLSession.shared.data(from:). The thread is released while waiting..onAppear→.task(id: url). When the view disappears, the task is cancelled; when a reused row'surlchanges, the previous task is cancelled and a new one starts.URLSession's async API observes cancellation and tears down the request. The wrong-row image went away with it — no storedTaskvariable, noonDisappear. ResettinguiImageat the top ofload()keeps the old row's image from lingering until the new one arrives.UIImage(data:)→await byPreparingForDisplay(). Decoding is CPU work, so it finishes on a pool thread and main receives a finished bitmap. It was never going to run wider than the core count anyway.guard !Task.isCancelled. If the row has already scrolled away by the time the response lands, skip the decode and the cache write.DispatchQueue.main.asyncis gone..taskinherits the view's isolation —@MainActor, on the Xcode 15+ SDK whereViewis main-actor-isolated — so the line afterawaitthat assignsuiImageis already on main.- The cache became
actor ImageCache. Dozens of tasks read and write it concurrently with no locks, and it gained TTL and cost-based eviction along the way. catch {}is deliberately empty. Cancellation arrives asCancellationError, which is a normal exit, not a failure. A network failure leaves the placeholder, which is the intended behavior.
5.2 Before and After
Measured in the reproduction project: a 200-row List, 600×600 thumbnails, a local server answering in 250 ms, a programmatic flick through all 200 rows and back (7.3 s), three runs each. iOS Simulator, iPhone 17, on an M1 Pro.
Before After
-------------------------------------------------------------------- -------------------------------------------------------------- -----------------
Peak thread count (baseline 11–16) 70–72 11–13
Threads parked in Data(contentsOf:) 64–66 0
Scroll 38 fps, 2–4 stalls totaling 3.7 s; the 7.3 s flick took 10.3 s 60 fps, no stalls
Memory footprint 28 → 158 MB 28 → 32 MB
Requests completed / cancelled 274 / 0 9 / 377
Images landing in a row whose url had changed (refresh every 400 ms) 186 of 393 (47%) 0
Requests still finishing after the scroll stopped 9.7 s 0.4 sA sample of the Before run during the scroll-back explains the stalls. The main thread spent 80% of those four seconds inside the Core Animation commit, in dispatch_group_wait: SwiftUI had handed an image decode to a GCD global queue and was waiting for it. Sixty-four GCD threads were parked in sendSynchronousRequest, the bottom of Data(contentsOf:). The decode couldn't start until a download finished and gave a thread back.
The memory line needs an honest footnote. The 158 MB is not thread stacks. It’s every thumbnail that ever scrolled past, downloaded, decoded and kept. After cancelled 377 of its 386 requests the moment their rows left the screen, so it decoded nine.
6. Making Sure It Doesn’t Come Back
Fixing it mattered less than making sure it wouldn’t reappear.
The next step in a real codebase is a search for Data(contentsOf:), String(contentsOf:), and DispatchQueue.global().async. Some hits are legitimate — a one-time read of a bundled file. The rest are this shape, synchronous I/O inside a global queue, and they get the same treatment.
What goes into the team conventions:
- No synchronous network calls inside
DispatchQueue.global().async. Network goes throughURLSession's async API or callbacks. - No synchronous I/O, semaphores, or
DispatchGroup.wait()insideasyncfunctions orTask. Callback APIs get a continuation; synchronous blocking APIs go outside the pool. - In SwiftUI, async work tied to a view’s lifecycle uses
.task/.task(id:), not.onAppear { Task { } }. ATaskstarted from a UIKit cell is always stored and cancelled inprepareForReuse. LIBDISPATCH_COOPERATIVE_POOL_STRICT=1in the Debug scheme. A violation stops the app during development, not in production.- One item on the code review checklist: “Is this work the CPU does, or work it hands off and waits for?”
Swift 6’s strict concurrency turns some of these into compile errors. Calling NSLock.lock() from an async context is rejected; a sending closure that mutates an outer variable is rejected. But synchronous I/O like Data(contentsOf:) is invisible to the compiler. The rules and the tooling have to go together.
Closing
Since this bug, two questions come first whenever I look at concurrency code.
1. Is this work the CPU does, or work it hands off and waits for?
If the CPU does it, the core count is the ceiling. Split it if it splits; if it doesn’t, wait. Changing tools won’t make it faster.
If the CPU waits for it, let await release the thread. Then the count stops mattering.
2. Is there somewhere this has to run?
The main thread, or a framework’s dedicated thread. If so, keep the heavy work off it.
These two questions settle most concurrency decisions. The code in the prologue had failed the first one: waiting and working were mixed in a single closure, and the waiting half was holding a thread. That was the whole story.
“Which queue should this run on?” → “Is this doing work, or waiting for work?” → “If it’s waiting, why is it holding a thread?”
GCD asked us where to run things. Swift Concurrency asks what the thing is. Swift Concurrency didn’t give us more threads. It gave us a way to wait without one.










