A short case study of my recent investigation into the edge-case behavior of Swift Futures.
6 min read

Swift: Can a Promise Be Called More Than Once in a Future?


Let’s dive into my recent experience working with Futures, Promises, and Combine.

Background

We have a legacy app with all kinds of code, even old Objective-C classes. I stumbled upon this when converting an old, massive ViewController into a new SwiftUI screen backed by a ViewModel.

Everything went smoothly until I stumbled upon a code that fetched data from another Obj-C class, which wasn’t our API class. Currently, our API calls are wrapped with Combine publishers inside ViewModels. Our API class contains a function that wraps the result into AnyPublisher which we can use in our data fetching pipelines in ViewModels. However, this class didn’t have such a wrapper, so I needed to create one.

Data fetching through completion

The Objective-C method looked like this when translated into Swift.

class FakeApi {

    static let operationalSessionStatus: String = "Operational"

    func loadDataWithStatus(
        completion: @escaping (Bool, (any Error)?) -> Void
    ) -> String {
        let status = FakeApi.operationalSessionStatus
        DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
            completion(true, nil)
        })
        
        return status
    }

}

Notice that it not only returns data asynchronously through the completion handler, but also synchronously returns a status String.

When the loadDataWithStatus was called in a ViewController, ViewController checked the status returned by the function right away and conditionally canceled the loading and the request:

let fakeApi = FakeApi()

func loadDataWithCompletion() {
    let status = fakeApi.loadDataWithStatus(
        completion: { result, error in
            print(result)
        }
    )
    
    if status != FakeApi.operationalSessionStatus {
        // Cancel the loading and data fetching
    }
}

Solving the problem

Wrapping a function with a completion handler into a function that returns AnyPublisher can be achieved using a Future publisher. By looking into the documentation, we see that Future is:

  • A publisher that eventually produces a single value and then finishes or fails.

Let’s create func loadDataPublisher() -> AnyPublisher<Bool?, Error>, which returns a Future, inside of which we will call our API through loadDataWithStatus. Since loadDataWithStatus returns the status String, we have to handle it inside of the Future:

func loadDataPublisher() -> AnyPublisher<Bool?, Error> {
    return Future { [weak self] promise in
        guard let self else { return }
        
        let status = self.loadDataWithStatus { result, error in
            if let error {
                promise(.failure(error))
            } else {
                promise(.success(result))
            }
        }
        
        if status == FakeApi.operationalSessionStatus {
            promise(.success(false))
        }
    }
    .eraseToAnyPublisher()
}

Promise could get called twice

I immediately noticed something that didn’t add up. The function combines synchronous and asynchronous logic and can return a promise in both. The question that comes to mind is: what if the function returns a promise in the status check if statement, and then also in the completion of the request? The documentation states that a Future produces a single value and then finishes. I therefore assumed it would ignore any additional promises.

However, I was curious and therefore tried to discuss this with ChatGPT. It contradicted my assumption and suggested that calling a promise multiple times could be problematic. That left me only one course of action - try it out.

Returning multiple promises

So what actually happens when we try to return multiple promises?

To answer this question, I built a simplified version of a publisher - testLoadDataPublisher. In it, two promises are executed synchronously, along with a DispatchQueue.main.asyncAfter block that simulates an asynchronous operation. I also added print statements for debugging purposes.

func testLoadDataPublisher() -> AnyPublisher<Bool, Error> {
    return Future { promise in
        print("Future execution start")
        DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
            print("Future execution in a completion")
            promise(.success(false))
        })
        promise(.success(true))
        print("Future execution after promise")
        promise(.success(false))
    }
    .eraseToAnyPublisher()
}

We can trigger the execution of this publisher easily through:

func testLoadData() {
    testCancellable = testLoadDataPublisher()
        .sink { completion in
            print("Sink completion:\(completion)")
        } receiveValue: { value in
            print("Receive value:\(value)")
        }
}
let fakeApi = FakeApi()

Button {
    fakeApi.testLoadData()
} label: {
    Text("Execute Fake publishers")
}

Results


Let’s analyze the console logs:

Promise Call Multiple Times Console Log

First, we can see that all print statements inside the Future were executed. This means the code inside the Future continues running even after the first promise is called and delivered. Second, only one value was received from a publisher - Receive value:true, after which the sink received a completion event.

Summary

This means that Apple’s documentation is correct. It could be more explicit, but a Future does what it says. However, since we can see that those other print statements have been executed, we have to be extra careful when dealing with multiple promises. If you don’t include additional logic inside the Future and only return multiple promises, you’re probably safe — though as with any undocumented behavior, future iOS or Xcode updates could change this.

Next steps

If you want to be on the safe side, or handle some extra logic that should not run after the first promise has been called, you could add a nested function that handles calling a promise with the result, and any additional logic you may need to execute only once before the first promise gets executed:

func testNestedLoadDataPublisher() -> AnyPublisher<Bool, Error> {
    return Future { promise in
        var isCompleted = false
        
        func complete(_ result: Result<Bool, Error>) {
            guard !isCompleted else { return }
            print("Future execution - nested - complete")
            isCompleted = true
            promise(result)
        }
        
        print("Future execution - nested - start")
        DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
            print("Future execution - nested - in a completion")
            complete(.success(false))
        })
        complete(.success(true))
        print("Future execution - nested - after promise")
        complete(.success(false))
    }
    .eraseToAnyPublisher()
}
func testNestedLoadData() {
    testNestedCancellable = testNestedLoadDataPublisher()
        .sink { completion in
            print("Sink completion:\(completion)")
        } receiveValue: { value in
            print("Receive value:\(value)")
        }
}

Console log:

Future execution - nested - start
Future execution - nested - complete
Future execution - nested - after promise
Receive value:true
Sink completion:finished
Future execution - nested - in a completion

Conclusion

A Future delivers its promise only once. However, if you include additional logic inside a Future, use extra caution to ensure it doesn’t run multiple times unintentionally.