an eagle flying in the sky

Cancel in-flight network requests using Combine

A common requirement for many apps is to cancel a running network request that has not yet completed, if a new request to the same resource is made. This is extremely simple using the power of reactive programming.

Let’s see how this can be achieved using Combine. Below is a simple example to demonstrate the key points. We are going to use JSON Placeholder to fetch a ToDo by identifier. We need a model object, a function which takes an identifier and returns a formatted url request, and another function which takes a request and returns a data task publisher.

public struct ToDo: Decodable {
    let userId: Int
    let id: Int
    let title: String
    let completed: Bool
}
public func request(for id: Int) -> URLRequest {
    URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/todos/\(id)")!)
}

public func dataPublisher(for request: URLRequest) -> AnyPublisher<Data, URLError> {
    URLSession.shared
        .dataTaskPublisher(for: request)
        .map(\.data)
        .eraseToAnyPublisher()
}

We can now use these to experiment with how requests are made, simulating hitting our network request multiple times in succession with different ToDo identifiers. You can easily imagine a real world situation where this could happen. A common scenario is search, where typing into a search bar triggers network requests and you only want the response from the latest string typed. Checkout this post for an example using search.

Back to ToDo’s. Let’s try the simplest method of making new requests for different identifiers. By using subjects in playgrounds, we can prototype this logic without needing to build any UI to trigger the requests.

var subscriptions = Set<AnyCancellable>()
let fireAllSubject = PassthroughSubject<Int, Never>()

fireAllSubject
    .map { request(for: $0) }
    .flatMap { dataPublisher(for: $0) }
    .decode(type: ToDo.self, decoder: JSONDecoder())
    .replaceError(with: .empty)
    .sink { print("(fire all) todo:", $0) }
    .store(in: &subscriptions)

fireAllSubject.send((1))
fireAllSubject.send((2))
fireAllSubject.send((3))
fireAllSubject.send((4))
fireAllSubject.send((5))
fireAllSubject.send((6))
fireAllSubject.send((7))

We use a PassthroughSubject to simulate user interaction which triggers an event related to a specific ToDo identifier. This is map’d into a url request and then flatMap’d to a data task publisher which performs the network request. The response is decoded into a ToDo model and for simplicity any errors are simply replaced with an empty ToDo. A total of 7 requests are fired in quick succession, which results in the output below.

(fire all) todo: ToDo(userId: 1, id: 3, title: "fugiat veniam minus", completed: false)
(fire all) todo: ToDo(userId: 1, id: 2, title: "quis ut nam facilis et officia qui", completed: false)
(fire all) todo: ToDo(userId: 1, id: 7, title: "illo expedita consequatur quia in", completed: false)
(fire all) todo: ToDo(userId: 1, id: 6, title: "qui ullam ratione quibusdam voluptatem quia omnis", completed: false)
(fire all) todo: ToDo(userId: 1, id: 5, title: "laboriosam mollitia et enim quasi adipisci quia provident illum", completed: false)
(fire all) todo: ToDo(userId: 1, id: 4, title: "et porro tempora", completed: true)
(fire all) todo: ToDo(userId: 1, id: 1, title: "delectus aut autem", completed: false)

So all 7 requests are fired and the sink operator is called with the response 7 times in a completely different order to which the requests were made. This is not ideal. What we want is for only the most recent request to be processed and for any requests that are still in-flight to be cancelled. These types of situations are where the beauty and simplicity of reactive programming really shines. The only change to make is 2 lines of code. Rather than flatMap into the data task publisher, simply change this to map and then use the power of switchToLatest() to cancel any requests that have not yet completed. 😎

let fireRecentSubject = PassthroughSubject<Int, Never>()

fireRecentSubject
    .map { request(for: $0) }
    .map { dataPublisher(for: $0) }
    .switchToLatest()
    .decode(type: ToDo.self, decoder: JSONDecoder())
    .replaceError(with: .empty)
    .sink { print("(fire recent) todo:", $0) }
    .store(in: &subscriptions)

fireRecentSubject.send((1))
fireRecentSubject.send((2))
fireRecentSubject.send((3))
fireRecentSubject.send((4))
fireRecentSubject.send((5))
fireRecentSubject.send((6))
fireRecentSubject.send((7))

Now the output is just a single ToDo with an identifier of 7. This is from the most recent request that was made!

(fire recent) todo: ToDo(userId: 1, id: 7, title: "illo expedita consequatur quia in", completed: false)

Feel free to grab this playground from Github. I absolutely ❤️ reactive programming and how complex scenarios can be solved with simple reactive operator chains. We touched on some very basic error handling today, but stay tuned for more of a deep dive into how errors should be handled in Combine. 🚜

Stay in the Loop

Subscribe to tapdev and never miss a post.

Total
0
Shares
Previous Post
aerial shot of green milling tractor

A better way to structure Combine with UIKit in MVVM

Next Post
cyclone fence in shallow photography

Simplify your protocol dependencies using functions

Related Posts
woman looking at the map

Convert array types using the map operator in Combine

In reactive programming, it's very common to require a stream of values in the form of an array. A typical use case for this is to populate a list with data. However, the array that is published from the source may need to be transformed to an array of values of a different type for display.
Read More