Free giveaway: Win a ticket for AppDevCon. Learn more.
Free: Win a ticket for AppDevCon.
Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

What is Structured Concurrency?

When we talk about Swift Concurrency, we also often mention Structured Concurrency. It’s a fundamental part of async/await in Swift and helps us understand how Swift’s latest improvements in concurrency work.

Before async/await, we wrote our asynchronous methods using closures and Grand Central Dispatch (GCD). This worked well but often resulted in a so-called closure hell—missing an overview due to chained closures. Structured concurrency makes asynchronous code easier to follow, but we still have unstructured tasks. Therefore, it’s time to dive into a fundamental aspect of Swift Concurrency.

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:

  1. Tasks are created and awaited in a clear, structured way—from top to bottom.
  2. The parent task waits for child tasks to finish before continuing.
  3. 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.

Join the waitlist: Swift Concurrency Course

I’m launching a new course on Swift Concurrency soon! Join the waitlist to be the first to know when it drops and unlock an exclusive early-bird discount:

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 result1 {
                        case .success(let data3):
                            fetchData { result2 in
                                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 article 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.

Read more about this in my dedicated article on async/await.

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. I won’t handle them in detail in this article, but for now, these are the most important characteristics of an unstructured task:

  • Not tied to a parent: They exist independently and don’t automatically inherit cancellation behavior.
  • Manual cancellation required: Developers need to manage task cancellation explicitly.
  • More flexibility, but more risk: While they offer more control, they also introduce potential pitfalls like race conditions or orphaned tasks.

Therefore, it’s best to stick to structured tasks as much as possible so you can benefit from structured concurrency.

Conclusion

Structured concurrency allows us to go 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 has its challenges, too!

If you’re keen to learn more about Swift Concurrency, make sure to check out my course or read any of my other Swift Concurrency articles.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.