When we talk about Swift Concurrency, we often also talk about Structured Concurrency—but what does that mean?
What does Structured Concurrency stand for?
Structured Concurrency is a model that makes asynchronous code easier to read, maintain, and reason about.
Before structured concurrency, asynchronous code often relied on callback hell or manually managed tasks using DispatchQueue or OperationQueue. This led to scattered execution flows, making it hard to understand the order of execution.
With structured concurrency, Swift ensures that child tasks stay within a defined scope, meaning:
- Tasks are created and awaited in a clear, structured way—from top to bottom.
- The parent task waits for child tasks to finish before continuing.
- Errors are automatically propagated, reducing the need for manual error handling across multiple completion handlers.
Especially error handling is so much easier with structured concurrency. You’ll no longer have the optional error parameters inside closures or endless error unwrapping that would clutter your code.
Example: Structured Concurrency in action
The concept of Structured Concurrency is best explained with an example. Let’s say we want to fetch three pieces of data asynchronously before displaying the result.
Fetching data with closures
Without structured concurrency, we would use traditional callbacks. This could result in the following code example:
func fetchData(completion: @escaping (String) -> Void) {
let seconds = 1.0 // Simulating network delay
DispatchQueue.global().asyncAfter(deadline: .now() + seconds) {
completion("Data")
}
}
func loadData() {
fetchData { data1 in
fetchData { data2 in
fetchData { data3 in
print("Finished loading: \(data1), \(data2), \(data3)")
}
}
}
}
These are still relatively simple methods and we don’t do much with the closure bodies, but it’s already becoming quite cluttered. There are a few problems with this approach:
- The indentation grows deeper with every nested callback (callback hell).
- Hard to follow the order of execution.
- Error handling gets complicated.
We’ve now named the callback properties data1, data2, and data3, making it a bit more obvious how requests will flow. However, without these you might think that the inner callback will execute before the outer. On top of that, what if we would also have the traditional error inside the callback:
func fetchData(completion: @escaping (String, Error?) -> Void) {
let seconds = 1.0 // Simulating network delay
DispatchQueue.global().asyncAfter(deadline: .now() + seconds) {
completion("Data", nil)
}
}
func loadData() {
fetchData { data1, error in
guard error == nil else {
print("Request 1 failed!")
return
}
fetchData { data2, error in
guard error == nil else {
print("Request 2 failed!")
return
}
fetchData { data3, error in
guard error == nil else {
print("Request 3 failed!")
return
}
print("Finished loading: \(data1), \(data2), \(data3)")
}
}
}
}
The code has already become quite complicated. We could use the result enum instead of the error, but the code would not improve:
func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
let seconds = 1.0 // Simulating network delay
DispatchQueue.global().asyncAfter(deadline: .now() + seconds) {
completion(.success("Data"))
}
}
func loadData() {
fetchData { result1 in
switch result1 {
case .success(let data1):
fetchData { result2 in
switch result2 {
case .success(let data2):
fetchData { result3 in
switch result3 {
case .success(let data3):
print("Finished loading: \(data1), \(data2), \(data3)")
case .failure:
print("Request 3 failed!")
}
}
case .failure:
print("Request 2 failed!")
}
}
case .failure:
print("Request 1 failed!")
}
}
}
I’ve honestly even had a hard time writing this code example for this lesson without failures, ha! It’s clear that there needs to be a better solution for asynchronous code—Structured Concurrency.
Fetching data with Structured Concurrency (async/await)
Taking the last code example, we can rewrite that using Structured Concurrency and async/await:
func fetchData() async throws -> String {
try await Task.sleep(for: .seconds(1)) // Simulating network delay
return "Data"
}
func loadData() async throws {
let data1 = try await fetchData()
let data2 = try await fetchData()
let data3 = try await fetchData()
print("Finished loading: \(data1), \(data2), \(data3)")
}
It reads so much better that you would almost doubt whether this is real. This code example has several improvements:
- Clear Execution Order: The code reads from top to bottom—just like synchronous code.
- Easier to Maintain: No deep nesting of callbacks.
- Automatic Error Propagation: If fetchData() threw an error, it would bubble up naturally.
How about unstructured tasks?
You might have heard about unstructured tasks in Swift Concurrency. They exist indeed, and the most common example is a detached task. We’ll handle them in detail in module 3, but for now, it’s essential to focus on the concept of structured concurrency, as explained in this lesson.
Your goal should be to benefit from structured concurrency as much as possible, and you should be careful when creating unstructured escapes.
Summary
We barely touched the surface of Swift Concurrency, but the code examples already demonstrate its value. From ‘Callback Hell’ to code that’s easier to read and maintain. There’s no doubt Structured Concurrency is the future, but it’s also a challenging framework with more concepts to cover. Closures had their drawbacks, but Swift Concurrency definitely has its challenges, too! More about that later in this course. First, let’s find out the relationship between Swift 6 and Swift Concurrency.