The Great Callback Hell Bake-Off

| Ellen Shapiro

Working with asynchronous code on iOS when using closures to deal with completion blocks can lead to a tremendous amount of nested callbacks. This is a lovely style of code known as Callback Hell.

NOTE: Code which has been simplified for this blog post can be found at https://github.com/designatednerd/OperationComparison. If you'd rather watch a video of a talk based on this article, it's at Swift.Amsterdam.

For example, let's say you have several asynchronous operations: One which fetches a user, one which fetches an image from a given URL, and one which resizes a passed-in image. The basic method signatures would look something like this:

enum Result<T> {
  case success(_ item: T)
  case error(_ error: Error)
}

func fetchUser(completion: @escaping (Result<User>) -> Void) {
  ...
}

func fetchImage(for user: User, completion: @escaping (Result<UIImage>) -> Void) {
  ...
}

func resizeImage(_ image: UIImage, to size: CGSize, completion: @escaping (Result<UIImage>) -> Void) {
  ...
}

If you just want to call a single one of these methods, the code isn't too bad:

fetchUser { [weak self] result in
  switch result {
  case .success(let user):
    self?.usernameTextField = user.username
  case .error(let error):
    debugPrint("Error fetching user: \(error)")
  }
}

You have a fairly clear idea of what you're requesting, and what you're getting back.

The problem comes when you want to chain several of these asynchronous methods together. Here, just using callbacks, is the code to fetch a user, get their avatar, then resize their avatar:

fetchUser { userResult in
  switch userResult {
  case .success(let user):
    fetchImage(from: user) { [weak self]  imageResult in
      switch imageResult {
      case .success(let image):
        guard let self = self else { return }

        resizeImage(image, to: self.imageView.frame.size) { resizedImageResult in
          switch resizedImageResult {
          case .success(let resizedImage):
            self?.imageView.image = resizedImage
          case .error(let error):
            debugPrint("Error resizing image: \(error)")
          case .error(let error):
            debugPrint("Error fetching image: \(error)")
          }
        }
    case .error(let error):
        debugPrint("Error fetching user: \(error)")
    }
  }
}

This deep nesting of callbacks is what's known as Callback Hell. It's awful to read and can be an enormous pain to debug, so iOS developers are always looking for ways to be able to chain asynchronous operations more clearly.

There are a few solutions possible to this problem, all of which have their trade-offs. There are three I'll cover here, each involving a larger investment of time to learn something you haven't work with before, but each giving you more power.

Important Caveat!

Most of this article was written before Apple's Combine reactive framework was announced at WWDC 2019, so it does not cover that framework. Looking forward to messing around with it over the summer though!

Solution #1: Custom infix Operators

In Swift, custom operators are operators which are defined by the developer. An infix operator is an operator which goes beween two items. For example in the code let amount = 2 + 3, + is an infix operator since it is combining the two things it is in the middle of in order to produce a result.

Declaring something an infix operator means allowing you to combine the left and right sides for whatever overrides of the operator you give it. Swift limits the available characters for custom operators, but there's still a reasonable amount of flexibility.

Vincente Pradilles gave a great lightning talk at NSSpain where he used his functional programming skillz to come up with a really interesting solution to this.

In Swift, functions are first-class types. This means can be both parameters and return values from functions. Normally, this is used as you saw in the callback hell examle - to allow passing a completion type.

However, you can also define a particular operator, and pass a function that takes a completion closure to another function which takes the result of the first closure and a second completion closure - returing a third closure function:

infix operator -->: MultiplicationPrecedence
func --><T,U>(_ firstFunction: @escaping (@escaping (Result<T>) -> Void) -> Void,
              _ secondFunction: @escaping (T, @escaping (Result<U>) -> Void) -> Void)
              -> (@escaping (Result<U>) -> Void) -> Void {
  return { completion in
    firstFunction { result in
      switch result {
      case .success(let item):
        secondFunction(item) { result2 in
          completion(result2)
        }
      case .error(let error):
        completion(.error(error))
      }
    }
  }
}

At first glance, this looks like a salad of generics and functions. What on earth is going on here? First, let's look at the function signature, which is by far the most complicated part.

Overriding the --> function tells the compiler, "Here is an implementation of this operator when the left hand side is the first function, the right hand side is the second function, and the desired return value is a third function."

Giving this override generic types of T and U allows you to make this flexible enough to handle having a second function which may or may not return a different type.

The firstFunction parameter takes a function which eventually returns Void. That function should have one parameter, which is a (Result<T>) -> Void completion block. Remember: func fetchUser(completion: (Result<User>) -> Void)? That's what will kick off this chain.

The secondFunction parameter takes another function which eventually returns Void. That function should have two parameters - one of which is the T type of the result of the first function, the other of which is a completion block for the second function, which is looking for type U.

The fetchImage function takes a User as the first parameter and a (Result<UIImage) -> Void completion block as the second closure -so we're off to a good start!

Now, we can create a chained equation:

let chain = fetchUser -> fetchImage

The chain constant will have the type (@escaping (Result<UIImage>) -> Void)) -> Void - This is similar to declaring a function like this: func chain(completion: @escaping(Result<UIImage>) -> Void)), but it assigns the function to the chain constant instead of creating a reusable function.

Now, you can call chain as if it were the function:

chain({ [weak self] result in
  switch result {
  case .success(let userImage):
    // TODO: Get resized image?
  case .error(let error):
    debugPrint("Error occurred: \(error)")
  }
})

This is a great place to start. One key problem with this method though: If you want to chain multiple functions, they always have to have a very similar signature. This doesn't help us with resizing the image, because that method takes three parameters: The image to resize, the size to make it, and the completion closure.

In order to make that work with chaining, you have to shoehorn it into another method with the same signature, presumably in the view controller where you wish to display it and where you have access to the size of the view where it's being displayed:

private func resizeImageForImageView(_ image: UIImage,
                                     completion: @escaping (Result<UIImage>) -> Void) {
  resizeImage(image, to: self.imageView.frame.size, completion: completion)
}

Once that's done, you can do this:

let chain = fetchUser -->
            fetchImage -->
            self.resizeImageToFitImageView

The (@escaping (Result<UIImage>) -> Void)) -> Void that was returned from chaining the first two functions is now passed in as the left hand side of the --> operator to the third function, and another (@escaping (Result<UIImage>) -> Void)) -> Void functioned that encompasses what has happened with all three functions will be assigned to chain.

You can still call chain as if it were a function, but now it will have the result of all three operations in one place:

chain({ [weak self] result in
  switch result {
  case .success(let resizedImage):
    self?.imageView.image = resizedImage
  case .error(let error):
    debugPrint("An error occurred: \(error)
  }
})

You can lose some context around exactly where an error occurred with this method, but if you don't really care in the end, this is a really, really helpful model.

Custom infix TL;DR:

Advantages

  • No need for a third party framework
  • You get to feel like a magician with functional programming
  • If it doesn't do what you need, just override the operator function again.

Disadvantages

  • Method signatures can get really ugly and hard to parse
  • Locks you in to a particular style of completion closure unless you do a ton of overrides
  • No way of accounting for threading in this model
  • No way to cancel chains once they're in-flight
  • Not necessarily something other developers will have worked with before.

Solution #2: PromiseKit

PromiseKit is one of several Swift implementations of the Future/Promise pattern, a concept which allows you to declare what will happen when a specified asynchronous operation completes in a way that's more chainable and easier to read.

A Promise essentially tells you that "at some point, I am expecting to get an object of this type - and I will let you know when that happens so you can proceed." If errors are received, they're piped through a separate system and are handled separately.

With PromiseKit, instead of taking a completion closure of (Result<T>) -> Void as a parameter, a function should return a Promise<T>.

It's reasonably easy to wrap an existing which takes a completion closure with a promise. For example, fetching the user returns something like this:

func fetchUser() -> Promise<User> {
  return Promise { seal in
    fetchUser { // <-- existing method
    switch result {
      case .success(let user):
        seal.fulfill(user)
      case .error(let error):
        seal.reject(error)
      }
    }
  }
}

You create the promise, which will be kicked off eventually by PromiseKit. The seal will tell PromiseKit's chaining system when the operation is complete, either fulfilling the promise with the object you promised would be there, or rejecting the promise with an error.

If you wrap all three methods in this example with Promises, their function signatures would be:

func fetchUser() -> Promise<User> {
  ...
}

func fetchImage(for user: User) -> Promise<UIImage> {
  ...
}

func resizeImage(_ image: UIImage, to size: CGSize) -> Promise<UIImage> {
  ...
}

Already, this is a bit easier to read and understand, because we've abstracted away the @escaping completion closures.

Next, you can use a bunch of built-in methods in PromiseKit to set up a chain of requests:

fetchUser()
  .then { user in
    fetchImage(for: user)
  }
  .then { [weak self] image -> Promise<UIImage> in
    guard let self = self else { throw GenericException.selfWasDeallocated }

    return resizeImage(image, to: self.imageView.frame.size)
  }
  .done { [weak self] resizedImage in
    self?.imageView.image = resizedImage
  }
  .catch { error in
    debugPrint("Error occurred: \(error)")
  }

Note that then by default expects a Promise to be returned - so if you're only adding one line, and that line is a function which returns a Promise, then it will automatically infer that as the return type.

If you have to do other work (like validating that self hasn't been deallocated, as in the 2nd then above), then you have to give the compiler a few more hints: You need to explicitly declare the return type, and explicitly return the promise from resizeImage....

This code reads much more synchronously, even though it's being executed asynchronously. Each then or done block doesn't execute until the Promise it's chained to fulfills. If a Promise is rejected anywhere in the chain, it's piped to the catch block and the other then blocks and the done block don't execute.

You can see a clear path from one step in the chain to another. The done block returns a Promise<Void> so if you want to wrap the whole chain in a function, you can return there. This is really useful for building small chains of promises into increasingly large chains that can power your whole app.

Alternately, you can add a catch block to the end of your chain to handle any error that happens locally. You can't chain anything on to a catch block, so it's always going to be the last thing in a chain.

By default, then and done functions will fire on the main thread. However, those methods (and many others within PromiseKit) also accept a DispatchQueue as an argument for where to perform the work within the promise.

This gives you fine-grained control over threading in a way that's just not possible with the custom operator method. You can choose from the call site whether something is doing a big enough operation that it needs to be fired off from a background thread, rather than having to do that work within the method as you would with custom operators.

PromiseKit TL;DR:

Advantages

  • Way more readable than traditional callback hell both at the call site and at the declaration of the function.
  • Has built-in methods of firing requests off on a specified thread (though by default the result is always on the main thread).
  • Automatic piping of errors and tons of methods around recovering from/reacting to errors.

Disadvantages

  • Third-party framework, which means while you're theoretically not responsible for maintenance, you have way more you have to try to understand when something breaks.
  • Designed primarily for handling sequential async events, so if you need to handle two concurrent async events it's probably not a big enough hammer.
  • Cancellation is not really possible in the current version (although it is in progress!)

Solution #3: RxSwift/RxCocoa

RxSwift is a Swift implementation of the ReactiveX style of programming. The idea is that almost everything in your application is a reaction to something else which has happened, and that you should chain reactions to actions rather than needing to explicitly tell the computer "When x happens, do y."

RxSwift is particularly powerful because you can combine all of its observable methods and power your UI directly with them.

The actual call to set things up to get the image is not that different from what it looks like in PromiseKit:

let image = FakeAPI.rxFetchUser()
  .flatMap { user in
    RealAPI.rxFetchImage(for: user)
  }
  .flatMap { image in
    ImageResizer.rxResizeImage(image, to: self.imageView.frame.size)
  }

image here is an Observable which you can subscribe to and get information about what happened along the chain. In this case, it's a particular type of Observable called a Single, which basically means that it will fire once instead of repeatedly.

When you subscribe to a Single, you are able to take actions based on whether that Single fails or succeeds:

image
  .subscribe(
    onSuccess: { [weak self] resizedImage in
      guard let self = self else { return }

      self.imageView.image = resizedImage
      self.operationLabel.text = "Complete (RxSwift) in \(self.formattedSecondsSince(start))!"
    },
    onError: { [weak self] error in
      self?.operationLabel.text = "Error occurred (RxSwift): \(error)"
    }
  )
  .disposed(by: bag)

With a more complex operation, you're able to continue listening to a stream of events - and react to each one in turn, or handle any error which occurs.

You're also able to combine observables in a way that allows you to use a related library called RxCococa to bind these actions to your UI. RxCocoa provides the concept of a Driver, which can interact with your UI automatically.

For instance, if you want to show a progress indicator while the image operation is running, you can create a secondary observable that tracks whether the first observable is running, then convert that into a Driver:

let running = Observable
  .from([
    image.map { _ in false }.asObservable()
  ])
  .merge()
  .startWith(true)
  .asDriver(onErrorJustReturn: false)

Note that this Driver starts with a given value since the operation would already be running. It also doesn't actually do anything but return false when there's an error since the error would be handled elsewhere, and the main thing you want to know - is the operation running or not? - can now be answered.

This bit of code also points up one of Rx's disadvantages: Its verboseness and density. Why is there a merge there? I have to be honest, I don't completely understand. The learning curve on making things work can be pretty brutal.

But the reward comes when you're able to easily set up your loading indicator to automatically show and hide based on whether your request is running or not:

running
  .drive(activityIndicator.rx.isAnimating)
  .disposed(by: bag)

Once the running driver is told to drive the rx.isAnimating property of your loading indicator, it will automatically handle flipping that on and off. And if you have something potentially combining several operations that needs to drive your UI, this is a dead-simple way of making sure all operations have completed before hiding your loading indicator.

Rx also makes heavy use of Scheduler objects to handle threading issues for you - you can easily specify what Scheduler you want a particular operation to run on if you'd like, similar to PromiseKit. Some Observable types will come with a default to a particular Scheduler - for instance, since Drivers are used to drive UI, they're always on the main thread's `Scheduler.

RxSwift/RxCocoa TL;DR:

Advantages

  • Smooth handling of both asynchronous and
  • Ability to easily bind properties to changing values.
  • Ability to compose

Disadvantages

  • Same third-party framework disadvantages as PromiseKit, only more so because it's a larger, more complex project.
  • Very steep learning curve
  • Having to learn schedulers vs. threads vs. dispatch queues to deal with threading
  • This is a pretty significant hammer, and a goodly percentage of the time, your nail isn't actually that big.

Conclusion: What would I use?

I really love the idea of the custom operators, but it has some significant limitations around cancellation and threading. In the end, my personal opinion is that this is a problem best solved by the community, because it is such a monstrously complex problem. (I'd really be interested in seeing community-based custom operator solutions, though!) That leaves me with RxSwift and PromiseKit.

RxSwift is extremely powerful, but in my experience I've found it to be a hammer that's way too big for most of the nails it's applied to. It makes some complex things very simple, but it makes some simple things unreasonably complex. If I've got something super-complicated going on, I will definitely use it. But personally, I usually find it to be total overkill.

I started using PromiseKit on a side project that had a severe problem with callback hell, and it's been a huge help. I've been able to have a much clearer chain of what's happening, facilitating debugging.

I've also found that when working with PromiseKit, I have way fewer modifications that I need to make to existing code to take advantage of what PromiseKit can do, and for me, I think that's it's greatest advantage.