Optimise your networking layer with Combine

Efficient networking for SwiftUI apps

Published: January 24, 2022 - 5 min read

With high-speed, low-latency internet being available in most places, it is easy to forget that not all of our users might be on a fast, low latency uplink when they’re using our apps. You don’t even have to go to some remote place to experience patchy and unreliable connectivity. I live in Hamburg, Germany, and even though high speed mobile internet is ubiquitous, there are quite a few spots along some of the main overground transport links that have no or very bad connectivity.

When building apps that access the internet, we should be mindful of this, and make sure we don’t waste bandwidth.

In this part of our series, I want to focus on how we can optimise network access in our apps when using Combine.

Previously…

Last time, we created a Combine pipeline for checking the availability of the username the user chose, and connected this pipeline to a simple sign-in form written in SwiftUI.

When running the app and inspecting the logs of our test server, we noticed that the isUserNameAvailable endpoint got called multiple times for each character typed. This is clearly not ideal: not only does it waste CPU cycles on our server (which might become an issues if you're hosting your server with a cloud provider that charges you by the number of calls or CPU uptime); it also means we're adding extra network overhead to our application.

You might not notice this when running the test server locally, but you will notice it when you're on an Edge connection, talking to a remote instance of your server.

The problem gets worse if your API endpoints aren’t idempotent: imagine calling an API endpoint for reserving a seat or buying a concert ticket. By sending two (or more) requests instead of one, you would end up reserving more seats than you require, or buying more concert tickets than you wanted.

So - how can we fix this?

Identifying the root cause

Well, first of all we need to find out what’s causing all those extra requests.

An easy way to figure out what’s going on with a Combine pipeline is to add some debugging code. Let's add the print() operator to the pipeline:

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .print("username")
    .flatMap { username -> AnyPublisher<Bool, Never> in
      self.authenticationService.checkUserNameAvailable(userName: username)
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}()

This operator logs a couple of useful things to the console:

  1. Any life cycle events of the pipeline (e.g. subscriptions being added)
  2. Any values being sent / received

We can specify a prefix (”username”) to make the log statements stand out on the console.

Running the app again, we immediately see the following output - even without typing anything into the text field:

username: receive subscription: (PublishedSubject)
username: request unlimited
username: receive value: ()
username: receive subscription: (PublishedSubject)
username: request unlimited
username: receive value: ()

This indicates we’ve got two subscribers for our pipeline!

Looking at our code, we can spot those subscribers in the initialiser of the view model:

init() {
  isUsernameAvailablePublisher
    .assign(to: &$isValid)
  
  isUsernameAvailablePublisher
    .map { $0 ? "" : "Username not available. Try a different one."}
    .assign(to: &$usernameMessage)
}

The first subscriber is the pipeline that feeds the isValid property, which we ultimately use to enable / disable the submit button on the sign in form.

The second subscriber is the pipeline that produces an error message in case the chosen username is not available. The result of this pipeline will be displayed on the sign in form as well.

Now that we’ve identified what’s causing multiple subscriptions to our publisher, let’s see what we can do to use only one subscriber.

Using the share operator to share a publisher

Having multiple subscribers for a single publisher is a common pattern, especially in UIs, where a single UI element might have an impact on multiple other elements.

If you need to share the results of a publisher with multiple subscribers, you can use the share() operator. According to Apple's documentation:

The publisher returned by this operator supports multiple subscribers, all of whom receive unchanged elements and completion states from the upstream publisher.

This is exactly what we need. By applying the share operator to the end of the pipeline in isUsernameAvailablePublisher, we share the result of the pipeline for each event (i.e., each character the user enters in the username input field) with all subscribers of the publisher:

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .print("username")
    .flatMap { username -> AnyPublisher<Bool, Never> in
      self.authenticationService.checkUserNameAvailable(userName: username)
    }
    .receive(on: DispatchQueue.main)
    .share()
    .eraseToAnyPublisher()
}()

When running the updated code, we can see that the $username publisher no longer has two subscribers, but instead just one:

username: receive subscription: (PublishedSubject)
username: request unlimited
username: receive value: ()

Now, you might be wondering why it’s only one subscriber, since we clearly still have two published properties (isValid and usernameMessage) subscribed to the pipeline.

Well, the answer is simple: the share operator ultimately is this one subscriber, and it in turn is being subscribed to by isValid and isUsernameAvailablePublisher. To prove this, let's add another print() operator to the pipeline:

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .print("username")
    .flatMap { username -> AnyPublisher<Bool, Never> in
      self.authenticationService.checkUserNameAvailable(userName: username)
    }
    .receive(on: DispatchQueue.main)
    .share()
    .print("share")
    .eraseToAnyPublisher()
}()

In the resulting output, we can see that share receives two subscriptions (1, 2), and username just one (3):

share: receive subscription: (Multicast) 1
share: request unlimited
username: receive subscription: (PublishedSubject) 3
username: request unlimited
username: receive value: ()
share: receive subscription: (Multicast) 2
share: request unlimited
share: receive value: (true)
share: receive value: (true)

You can think of share() as a fork that receives events from its upstream publisher and multicasts them to all of its subscribers.

Is it a bug or a feature?

Go ahead and type a few characters into the username field, and you will find that for every character you type you will still see two requests being made to the server.

This might be an issue in iOS 15 - I debugged into this a bit, and it seems like TextField emits every keystroke twice. In prior versions of iOS, this wasn't the case, and I am inclined to think this is a bug in iOS 15, so I created a sample project to reproduce this issue (see AppleFeedback/FB9826727 at main · peterfriese/AppleFeedback), and filed a Feedback (FB9826727) with Apple.

If you agree with me that this is a regression, consider filing a Feedback as well to - the more duplicates a bug receives, the more likely it is it will be addressed.

Using debounce to further optimise the UX

When building UIs that communicate with a remote system, we need to keep in mind that the user usually types a lot faster than the system can deliver feedback.

For example, when picking a username, I usually type my favourite username without stopping to type in the middle of the word. I don’t care if the first few letters of this username are available - I am interested in the full name. Sending the incomplete username over to the server after each single keystroke doesn’t make a lot of sense and seems like a lot of waste.

To avoid this, we can use Combine’s debounce operator: it will drop all events until there is a pause. It will then pass on the most recent event to the downstream publisher:

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .debounce(for: 0.8, scheduler: DispatchQueue.main)
    .print("username")
    .flatMap { username -> AnyPublisher<Bool, Never> in
      self.authenticationService.checkUserNameAvailable(userName: username)
    }
    .receive(on: DispatchQueue.main)
    .share()
    .print("share")
    .eraseToAnyPublisher()
}()

By doing so, we tell Combine to disregard all updates to username until there is a pause of 0.8 seconds, and the send the most recent username on to the next operator on the pipeline (in this case, the print operator, which will then pass the unchanged event on to the flatMap operator).

This suits a normal user input behaviour much more, and will result in the app sending fewer requests to the server.

Using removeDuplicates to avoid sending the same request twice

Have you ever spoken to a person and asked them the same question twice? It’s a bit of an awkward situation, and the other person probably wonders if you’ve been paying attention to them at all.

Now, even though AI is making advances, I am certain that computers don’t have emotions, so they won’t hold a grudge if you send the same API request twice. But - in the interest of giving our users the best experience possible, we should try to eliminate sending duplicate requests if we can.

Combine has an operator for this: removeDuplicates - it will remove any duplicate events from the stream of events if they follow each other subsequently.

This works really well in conjunction with the debounce operator, and we can use those two operators combined (sorry, I guess you’ll have to live with the puns) for a little further optimisation of our username availability check:

private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
  $username
    .debounce(for: 0.8, scheduler: DispatchQueue.main)
    .removeDuplicates()
    .print("username")
    .flatMap { username -> AnyPublisher<Bool, Never> in
      self.authenticationService.checkUserNameAvailable(userName: username)
    }
    .receive(on: DispatchQueue.main)
    .share()
    .print("share")
    .eraseToAnyPublisher()
}()

Together, they will further reduce the number of requests we send to our server in case the user mistypes and then corrects their spelling.

Let’s look at an example:

jonyive [pause] s [backspace]

This will send the following requests:

  1. jonyive
  1. no request for jonyives (as the s got deleted before the debounce timed out)
  1. no second request for jonyive, as this got filtered by removeDuplicates

It might be a small thing, but every little helps.

Closure

In this article, we discussed a number of ways how Combine can make communicating with a remote server (or any asynchronous API, in fact) more efficient.

By using the share operator, we can attach multiple subscribers to a publisher / pipeline, and avoid running expensive / time-consuming processing for each of those subscribers. This is particularly useful when accessing APIs that have a higher latency than an in-process module, such as a remote server or anything that involves I/O.

The debounce operator allows us to deal more efficiently with any events that occur in short bursts, like user input. Instead of processing every single event coming down the pipeline, we wait for a pause and only operate on the most recent event.

To avoid processing duplicate events, we can use the removeDuplicates operator. As the name suggests, it removes any directly subsequent duplicate events, such as the user adding and then removing a character when we also use the debounce operator.

Together, these operators can help us build clients that access remote servers and other asynchronous APIs in a more efficient way.

In the next episode of this series, we’re going to explore the topic of error handling and how to handle status-related server responses using Combine.

Thanks for reading 🔥


The header image is based on Fast Internet by The Icon Z from the Noun Project

Source Code
Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈