Logo for tanaschita.com

Backpressure in Combine

Understand the concept behind backpressure and how to apply it in Combine.

updated on 08 Jul 2024 · 8 min read

When working with subscribers in Combine, we mostly use sink(receiveValue:) and assign(to:on:) to receive an unlimited amount of values from a publisher. However, there are scenarios where processing these values takes longer, and new values continue to arrive.

To prevent blocking or overflow, we may need to control the rate at which values are received. This concept of limiting the elements the subscriber receives is called backpressure.

Let's look at some examples and strategies on how to solve them.

Sponsorship logo
Preparing for a technical iOS job interview - updated for iOS 18
Check out my book on preparing for a technical iOS job interview with over 200 questions & answers. Test your knowledge on iOS topics such as Swift, SwiftUI, Combine, HTTP Networking, Authentication, SwiftData & Core Data, Concurrency with async/await, Security, Automated Testing, Machine Learning and more.
LEARN MORE

Examples of backpressure

Backpressure issues may arise in various scenarios, such as:

  • Reading and writing data: When accessing slow storage media.
  • Server communication: When handling real-time data from a server.
  • Rendering: When updating the UI frequently.

For instance, in real-time server-to-client communication using WebSockets, emitting numerous values per second can lead to performance issues if the UI is updated with each incoming value. A better approach would be to buffer these values and update the UI at a controlled rate.

Another practical example is handling user search input. Instead of sending a server request with every keystroke, we can wait until the user stops typing for a specific amount of time before making a request.

Backpressure strategies

There are several strategies for applying backpressure in Combine, including:

  • Buffer i.e. accumulate incoming values temporarily
  • Drop i.e. skipping some of the incoming values

Both strategies can be implemented in different ways, either by creating a custom subscriber or by using Combine’s buffering or timing operators.

Using Combine's buffering and timing operators

Combine provides operators that can be attached to a standard subscriber to apply backpressure.

The debounce(for:scheduler:options:) operator

This operator publishes elements only after a specified time interval elapses. For example:

cancellable = subject
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.sink { value in
print("received \(value)")
}
subject.send(1)
subject.send(2)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.subject.send(3)
self.subject.send(4)
}

Output:

received 2
received 4

Values 1 and 3 are dropped due to the 0.5-second debounce interval.

The throttle(for:scheduler:latest:) operator

This operator produces one element per specified interval, sending either the newest or oldest element received during that interval.

cancellable = Timer.publish(every: 1.0, on: .main, in: .default)
.autoconnect()
.throttle(for: 3.0, scheduler: RunLoop.main, latest: true)
.sink{ date in
print("received \(date)")
}

Output:

received 2021-11-10 18:36:16
received 2021-11-10 18:36:19
received 2021-11-10 18:36:22
received 2021-11-10 18:36:25

We receive a date value every 3 seconds.

The collect(_:) operator

This operator bundles elements into an array and emits them based on a specified count or time interval.

cancellable = subject
.collect(2)
.sink { value in
print("received \(value)")
}
subject.send(1)
subject.send(2)
subject.send(3)
subject.send(4)

Output:

received [1, 2]
received [3, 4]

This approach is useful if the subscriber can process multiple elements at the same time.

Creating a custom subscriber to apply back pressure

If none of the above operators meets our needs, we can implement a custom subscriber that applies backpressure. Here is an example of a subscriber which receives one value initially and then two additional values after a second:

class CountSubscriber: Subscriber {
typealias Input = Int
typealias Failure = Never
var subscription: Subscription?
// 1.
func receive(subscription: Subscription) {
print("subscribed")
self.subscription = subscription
subscription.request(.max(1))
}
// 2.
func receive(_ input: Int) -> Subscribers.Demand {
print("received \(input)")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.subscription?.request(.max(2))
}
return Subscribers.Demand.none
}
// 3.
func receive(completion: Subscribers.Completion<Never>) {
}
}

Let's go though it step by step:

  1. receive(subscription:): Called as soon as a new subscription arrives. We request one element from the publisher.

  2. receive(_ input:): Called when an element is received. We request two additional values after one second.

  3. receive(completion:): Called when the publisher completes.

Using the subscriber might look as follows:

let subject = PassthroughSubject<Int, Never>()
let subscriber = CountSubscriber()
subject.subscribe(subscriber)
subject.send(1)
subject.send(2)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.subject.send(3)
self.subject.send(4)
self.subject.send(5)
}

Output:

subscribed
received 1
received 3
received 4
Sponsorship logo
Preparing for a technical iOS job interview - updated for iOS 18
Check out my book on preparing for a technical iOS job interview with over 200 questions & answers. Test your knowledge on iOS topics such as Swift, SwiftUI, Combine, HTTP Networking, Authentication, SwiftData & Core Data, Concurrency with async/await, Security, Automated Testing, Machine Learning and more.
LEARN MORE

Newsletter

Image of a reading marmot
Subscribe

Like to support my work?

Say hi

Related tags

Articles with related topics

combine

swiftui

swift

ios

How to delay server requests for user's search query with SwiftUI and Combine

Learn how to debounce on a practical example.

15 Jul 2024 · 5 min read

Latest articles and tips

© 2024 tanaschita.com

Privacy policy

Impressum