The Problems with Completion Handlers in Swift

Mar 28, 2023#concurrency#swift

Asynchronous programming is an essential part of modern software development, allowing applications to perform long-running tasks without blocking the user interface or other critical functionality. Completion handlers are a popular technique for implementing asynchronous code.

Completion handlers are a way to provide a callback function that is executed when an asynchronous task completes. Here’s an example of using a completion handler to perform an HTTP request:

func performRequest(url: URL, completion: @escaping (Data?, Error?) -> Void) {
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data, error == nil else {
            completion(nil, error)
            return
        }
        completion(data, nil)
    }
    task.resume()
}

While completion handlers can work well for simple cases like this, they become much more difficult to use when dealing with complex asynchronous code. Here are some of the problems with completion handlers:

Callback hell

When dealing with multiple asynchronous tasks, completion handlers can quickly lead to a problem known as “callback hell”. This occurs when you have to chain several completion handlers together, making the code difficult to read and maintain.

func login(username: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
    authenticateUser(username: username, password: password) { result in
        switch result {
        case .success(let authToken):
            getUserInfo(authToken: authToken) { result in
                switch result {
                case .success(let userInfo):
                    getUserSettings(authToken: authToken) { result in
                        switch result {
                        case .success(let userSettings):
                            let user = User(info: userInfo, settings: userSettings)
                            completion(.success(user))
                        case .failure(let error):
                            completion(.failure(error))
                        }
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

func authenticateUser(username: String, password: String, completion: @escaping (Result<String, Error>) -> Void) {
    // Perform authentication request...
}

func getUserInfo(authToken: String, completion: @escaping (Result<UserInfo, Error>) -> Void) {
    // Perform request to get user info...
}

func getUserSettings(authToken: String, completion: @escaping (Result<UserSettings, Error>) -> Void) {
    // Perform request to get user settings...
}

Error handling

Error handling with completion handlers can also be challenging. Errors can be propagated through the completion handler, but they can also be thrown or handled in other ways. This can make it difficult to handle errors in a consistent and predictable way, especially in complex applications.

func fetchUserProfile(completion: @escaping (UserProfile?, Error?) -> Void) {
    fetchUserId { userId, error1 in
        guard let userId = userId, error1 == nil else {
            completion(nil, error1)
            return
        }
        fetchUserDetails(for: userId) { userDetails, error2 in
            guard let userDetails = userDetails, error2 == nil else {
                completion(nil, error2)
                return
            }
            fetchUserPosts(for: userId) { userPosts, error3 in
                guard let userPosts = userPosts, error3 == nil else {
                    completion(nil, error3)
                    return
                }
                fetchPostComments(for: userPosts) { postComments, error4 in
                    guard let postComments = postComments, error4 == nil else {
                        completion(nil, error4)
                        return
                    }
                    let userProfile = UserProfile(userId: userId, userDetails: userDetails, userPosts: userPosts, postComments: postComments)
                    completion(userProfile, nil)
                }
            }
        }
    }
}

Here’s the same fetchUserProfile function written using async/await in Swift. The function is now shorter, more readable, and easier to reason about. Any errors are automatically propagated up the call stack as Swift errors, so we don’t need to worry about handling them with callbacks.

func fetchUserProfile() async throws -> UserProfile {
    let userId = try await fetchUserId()
    let userDetails = try await fetchUserDetails(for: userId)
    let userPosts = try await fetchUserPosts(for: userId)
    let postComments = try await fetchPostComments(for: userPosts)
    return UserProfile(userId: userId, userDetails: userDetails, userPosts: userPosts, postComments: postComments)
}

Synchronization

Completion handlers can also lead to synchronization issues, especially when dealing with shared state. For example, if you have two asynchronous tasks that need to update the same variable, you’ll need to use a lock or other synchronization mechanism to prevent race conditions.

var counter = 0
let lock = NSLock()

func incrementCounter(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        lock.lock()
        counter += 1
        lock.unlock()
        completion(counter)
    }
}

func decrementCounter(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        lock.lock()
        counter -= 1
        lock.unlock()
        completion(counter)
    }
}