The Fork-Join Pattern in Swift Concurrency
Swift Concurrency gives us two APIs for running work in parallel: async let and TaskGroup. They look quite different at first, but they're both expressions of the same underlying pattern, and I find that understanding that pattern makes the relationship between them much clearer.
Concurrency vs. parallelism
First, a useful starting distinction around terms (from Rob Pike): concurrency is about structure, parallelism is about execution.
Concurrency is a way of structuring a program so multiple logically independent operations can make progress independently — whether interleaved on one core or executing simultaneously on many.
On the other hand, parallelism is multiple operations physically executing at the same instant on separate hardware (multiple cores, GPUs). It’s a property of how the program runs.
The trouble with callbacks and promises
With those definitions in hand: the question for any concurrent language is how it lets you express that structure. Most languages give you callbacks or futures and promises. In their raw form, callbacks and promises tend to behave like a kind of cross-thread goto: control flow escapes lexical structure, and lifetimes become difficult to reason about locally. You may be familiar with goto's downfall with the advent of structured programming, thanks to some opinion pieces by Dijkstra (pronounced Deek-strah, by the way). This is, not coincidentally, where the "Structured" in Structured Concurrency comes from — it's the same idea of respecting lexical scope applied to a new domain and helps simplify a lot of the pain points of GCD.
If you're curious to explore this more, this article is a great walkthrough, particularly the breakdown of how each of these are semantically equivalent to go statements:
Registering a callback is semantically equivalent to starting a background thread that (a) blocks until some event occurs, and then (b) runs the callback. (Though obviously the implementation is different.) So in terms of high-level control flow, registering a callback is essentially a go statement.
Futures and promises are the same too: when you call a function and it returns a promise, that means it’s scheduled the work to happen in the background, and then given you a handle object to join the work later (if you want). In terms of control flow semantics, this is just like spawning a thread. Then you register callbacks on the promise, so see the previous bullet point.
The fork-join pattern
Swift's structured concurrency takes a different approach: a pattern called fork-join. The idea is simple — multiple operations start running concurrently (the fork), and then their results are combined once they all complete (the join). async let and TaskGroup are the two APIs that implement this pattern, and they differ along one key axis: whether the number of operations you want to fork is known at compile time (static width) or determined at runtime (dynamic width).
async let — static width
async let is fork-join for a fixed, known number of operations. If you have a predefined number of operations that always need to be performed together, you simply declare them one after the other. Each async let declaration is itself a fork point — the right-hand side begins executing as a child task the moment you write async let. The join happens when you await the binding:
swift
func loadProfile() async -> Profile {
async let user = fetchUser() // fork
async let posts = fetchPosts() // fork
async let avatar = fetchAvatar() // fork
return await Profile( // join
user: user,
posts: posts,
avatar: avatar
)
}All three fetches are in flight by the time we reach the return. The await is the join site — it’s where we wait for the children to finish and collect their results. The width here is static: there are exactly three child tasks, hardcoded into the source. You couldn’t write this with a variable number of fetches without restructuring.
TaskGroup — dynamic width
TaskGroup is fork-join for a runtime-determined number of operations. When the count or the work depends on values you only have at runtime, you open a group and add a child task for each unit of work you want to run. Each addTask call is a fork; the join occurs as you iterate over the group's results:
swift
func loadPosts(for ids: [UUID]) async -> [Post] {
await withTaskGroup(of: Post.self) { group in
for id in ids {
group.addTask { await fetchPost(id: id) } // fork
}
var posts: [Post] = []
for await post in group { // join
posts.append(post)
}
return posts
}
}The shape is the same — fork some children, join their results — but the count is determined by ids.count at runtime rather than baked into the source. If ids has three elements you get three child tasks; if it has three hundred, you get three hundred.
Conclusion
The static/dynamic distinction is usually the deciding factor on which to use. If you know at the call site exactly which operations you want to run concurrently, use async let — each child gets its own typed binding, and you read the results back by name. If the count or the work depends on runtime data, use TaskGroup, at the cost of homogeneous child result types and a bit more setup boilerplate.
Conceptually, though, they’re much closer than they first appear. Both APIs implement the same fork-join structure: child tasks are forked from a parent task, execute concurrently within that parent’s scope, and are joined before the scope completes. The difference is not in the underlying concurrency model, but in how the number of forks is expressed. Either way, you’re expressing the same idea: fork some work to run concurrently, then join the results when you need them.


I hope readers find that looking analyzing the options by understanding the pattern they implement and how they differ along the lines of concurrency width provides some different insights than a standard syntax walkthrough!