Introduction
When a Swift function makes multiple asynchronous API calls, you need a coordination mechanism to know when all calls are complete before returning the combined result. The standard approaches are DispatchGroup, async/await with TaskGroup (Swift 5.5+), or chaining completion handlers manually. DispatchGroup is the classic pre-concurrency approach, while async/await is the modern recommended pattern.
DispatchGroup (Pre-Concurrency)
DispatchGroup tracks a group of asynchronous operations and notifies when all are complete:
1func fetchAllData(completion: @escaping ([String: Any]) -> Void) {
2 var results: [String: Any] = [:]
3 let group = DispatchGroup()
4
5 // Request 1
6 group.enter()
7 fetchUser { user in
8 results["user"] = user
9 group.leave()
10 }
11
12 // Request 2
13 group.enter()
14 fetchPosts { posts in
15 results["posts"] = posts
16 group.leave()
17 }
18
19 // Request 3
20 group.enter()
21 fetchNotifications { notifications in
22 results["notifications"] = notifications
23 group.leave()
24 }
25
26 // Called when all three requests complete
27 group.notify(queue: .main) {
28 completion(results)
29 }
30}
Every enter() must have a matching leave(). When the enter/leave count reaches zero, notify fires.
DispatchGroup with Error Handling
1func fetchAllData(completion: @escaping (Result<[String: Any], Error>) -> Void) {
2 var results: [String: Any] = [:]
3 var firstError: Error?
4 let group = DispatchGroup()
5 let lock = NSLock() // Thread-safe access to shared state
6
7 group.enter()
8 fetchUser { result in
9 lock.lock()
10 switch result {
11 case .success(let user):
12 results["user"] = user
13 case .failure(let error):
14 if firstError == nil { firstError = error }
15 }
16 lock.unlock()
17 group.leave()
18 }
19
20 group.enter()
21 fetchPosts { result in
22 lock.lock()
23 switch result {
24 case .success(let posts):
25 results["posts"] = posts
26 case .failure(let error):
27 if firstError == nil { firstError = error }
28 }
29 lock.unlock()
30 group.leave()
31 }
32
33 group.notify(queue: .main) {
34 if let error = firstError {
35 completion(.failure(error))
36 } else {
37 completion(.success(results))
38 }
39 }
40}
async/await with TaskGroup (Swift 5.5+)
The modern approach uses structured concurrency:
1func fetchAllData() async throws -> (User, [Post], [Notification]) {
2 async let user = fetchUser()
3 async let posts = fetchPosts()
4 async let notifications = fetchNotifications()
5
6 // All three run concurrently, await collects results
7 return try await (user, posts, notifications)
8}
9
10// Usage
11Task {
12 do {
13 let (user, posts, notifications) = try await fetchAllData()
14 print("User: \(user.name), Posts: \(posts.count)")
15 } catch {
16 print("Error: \(error)")
17 }
18}
async let starts each task immediately and runs them concurrently. The await at the return site waits for all to complete.
TaskGroup for Dynamic Number of Requests
When the number of concurrent requests is not known at compile time:
1func fetchMultipleUsers(ids: [Int]) async throws -> [User] {
2 try await withThrowingTaskGroup(of: User.self) { group in
3 for id in ids {
4 group.addTask {
5 try await fetchUser(id: id)
6 }
7 }
8
9 var users: [User] = []
10 for try await user in group {
11 users.append(user)
12 }
13 return users
14 }
15}
Sequential Requests (Dependent Calls)
When one request depends on the result of another:
1// Completion handler approach
2func fetchUserAndPosts(userId: Int, completion: @escaping (User, [Post]) -> Void) {
3 fetchUser(id: userId) { user in
4 fetchPosts(for: user) { posts in
5 DispatchQueue.main.async {
6 completion(user, posts)
7 }
8 }
9 }
10}
11
12// async/await approach (much cleaner)
13func fetchUserAndPosts(userId: Int) async throws -> (User, [Post]) {
14 let user = try await fetchUser(id: userId)
15 let posts = try await fetchPosts(for: user)
16 return (user, posts)
17}
Converting Completion Handlers to async/await
Bridge existing callback-based APIs to the new concurrency model:
1// Existing callback-based function
2func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
3 URLSession.shared.dataTask(with: url) { data, response, error in
4 // ...
5 completion(.success(user))
6 }.resume()
7}
8
9// Wrap with withCheckedThrowingContinuation
10func fetchUser(id: Int) async throws -> User {
11 try await withCheckedThrowingContinuation { continuation in
12 fetchUser(id: id) { result in
13 switch result {
14 case .success(let user):
15 continuation.resume(returning: user)
16 case .failure(let error):
17 continuation.resume(throwing: error)
18 }
19 }
20 }
21}
Timeout for Multiple Requests
1func fetchWithTimeout() async throws -> [String: Any] {
2 try await withThrowingTaskGroup(of: (String, Any).self) { group in
3 group.addTask {
4 ("user", try await fetchUser())
5 }
6 group.addTask {
7 ("posts", try await fetchPosts())
8 }
9
10 // Timeout task
11 group.addTask {
12 try await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds
13 throw TimeoutError()
14 }
15
16 var results: [String: Any] = [:]
17 for try await (key, value) in group {
18 results[key] = value
19 if results.count == 2 { // Got both results
20 group.cancelAll()
21 break
22 }
23 }
24 return results
25 }
26}
Common Pitfalls
Mismatched enter()/leave() calls: If leave() is called more times than enter(), the app crashes. If leave() is never called (e.g., a network timeout with no error handler), notify never fires. Ensure every code path calls leave().
Not dispatching to the main queue for UI updates: Completion handlers from URLSession run on background threads. Always dispatch to DispatchQueue.main before updating UI, or use @MainActor with async/await.
Resuming a continuation twice: withCheckedThrowingContinuation crashes if resume is called more than once. Guard against callback-based APIs that might call the completion handler multiple times.
Capturing self strongly in closures: Completion handlers retain self strongly by default, causing retain cycles in view controllers. Use [weak self] in closures or switch to async/await where the compiler manages lifetimes.
Ignoring cancellation in TaskGroup: When one task in a TaskGroup throws, other tasks continue running unless you call group.cancelAll(). Check Task.isCancelled in long-running tasks to support cooperative cancellation.
Summary
Use DispatchGroup with enter()/leave()/notify() for pre-concurrency parallel requests
Use async let for a fixed number of concurrent requests (Swift 5.5+)
Use withThrowingTaskGroup for a dynamic number of concurrent requests
Use withCheckedThrowingContinuation to bridge callback-based APIs to async/await
Always use [weak self] in completion handler closures to prevent retain cycles
Ensure every DispatchGroup.enter() has a corresponding leave() in all code paths