One of the biggest changes in Swift since I’ve been away is how we handle concurrency. Back in iOS 15, we got our first taste of Swift Concurrency with async/await. At the time, most of us were still relying heavily on Combine or traditional closures to handle asynchronous work. I had played around with it a little back then, but its practical use was limited because it couldn’t be deployed widely on older iOS versions.

Fast forward a few years, and Swift Concurrency has become a core part of how we write asynchronous code. No more pyramid-of-doom closures, no more juggling multiple Combine publishers just to fetch some data, and no more worrying about which queue you’re on—Swift now gives us structured concurrency, task groups, actors, and more.

Let’s start with the basics.

From Closures to async/await

Before Swift Concurrency, networking calls were usually handled with completion handlers. For example, fetching some JSON might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func fetchPosts(completion: @escaping (Result<[Post], Error>) -> Void) {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    
    URLSession.shared.dataTask(with: url) { data, _, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        guard let data = data else {
            completion(.failure(NetworkError.noData))
            return
        }
        
        do {
            let posts = try JSONDecoder().decode([Post].self, from: data)
            completion(.success(posts))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

This works fine, but it quickly becomes verbose and harder to read as you add retries, multiple requests, or additional error handling.

With Swift Concurrency, the same request becomes much cleaner:

1
2
3
4
5
func fetchPosts() async throws -> [Post] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode([Post].self, from: data)
}

No nested closures, no explicit dispatching back to the main queue, and error handling is built right in using throws. It reads just like synchronous code, while still being fully asynchronous.

At the call site, it might look something like this

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@MainActor
class FeedViewModel: ObservableObject {
    @Published var posts: [Post] = []
    @Published var comments: [Comment] = []
    @Published var users: [User] = []
    
    func loadFeed() async {
        do {
            posts = try await fetchPosts()
            comments = try await fetchComments()
            users = try await fetchUsers()
        } catch {
            ...
        }
    }
}

We load the relevant data and update the view accordingly. In the current example, however, each operation runs sequentially, even though the methods don’t actually depend on one another. If loadFeed is cancelled, or if one of the calls fails, the remaining methods won’t execute.

In the next post, we’ll look at how to run these tasks in parallel, reducing overall load timech while still keeping the benefits of structured concurrency.