Swift
Asynchronous Programming
Closures
API Requests
Completion Handling

Asynchronous completion handling in a function with multiple closures/API requests in swift

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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:

swift
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

swift
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:

swift
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:

swift
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:

swift
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:

swift
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

swift
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

Course illustration
Course illustration

All Rights Reserved.