Debounce in Swift: Ditch Combine for This One Simple Loop
Source: Dev.to
Enter Swift Async Algorithms – Apple’s answer to making async operations feel as natural as a for loop
What is Swift Async Algorithms?
Swift Async Algorithms is Apple’s open‑source library that brings familiar collection algorithms to the world of async sequences. Think of it as the missing standard‑library support for async/await – it provides tools like debounce, throttle, merge, combineLatest, and more, but designed from the ground up for Swift’s modern concurrency model.
- Released in 2022, it’s Apple’s way of saying: “You don’t need Combine for everything anymore.”
- One‑line summary: It’s like the Combine framework, but built for async/await instead of publishers and subscribers.
Why Should You Care?
Imagine you’re building a search bar. Every time the user types, you want to query an API, but you don’t want to spam it with requests. You need debouncing.
| The old way (Combine) | The new way (Async Algorithms) |
|---|---|
| Import a whole framework | Use native Swift concurrency |
| Deal with publishers, subscribers, and cancellables | Write code that reads like English |
| Store subscription lifecycles | No subscription management |
| Convert between async and publisher worlds | Works seamlessly with async/await |
If you’re already using async/await in your app, why add Combine just for one operator?
Real‑World Example: Search‑Bar Debounce
Below are the same feature implemented with both approaches. The contrast should make the benefits of Async Algorithms crystal clear.
The Combine Way
import Combine
import UIKit
class CombineSearchViewController: UIViewController {
@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var tableView: UITableView!
private var cancellables = Set()
private let searchSubject = PassthroughSubject<String, Never>()
private var results: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
setupSearch()
}
private func setupSearch() {
// Set up the debounce pipeline
searchSubject
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] query in
Task {
await self?.performSearch(query: query)
}
}
.store(in: &cancellables)
searchBar.delegate = self
}
private func performSearch(query: String) async {
// Simulate API call
try? await Task.sleep(nanoseconds: 500_000_000)
let mockResults = [
"Result for \(query) - 1",
"Result for \(query) - 2",
"Result for \(query) - 3"
]
await MainActor.run {
self.results = mockResults
self.tableView.reloadData()
}
}
}
extension CombineSearchViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
searchSubject.send(searchText)
}
}
What’s happening
- A
PassthroughSubjectemits search‑text values. - Operators are chained:
debounce → removeDuplicates → sink. - Inside
sinkwe bridge to async withTask. - We must manage a
Setfor cleanup. - Two concurrency models (Combine + async/await) coexist.
The Async Algorithms Way
import AsyncAlgorithms
import UIKit
class AsyncSearchViewController: UIViewController {
@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var tableView: UITableView!
private let searchStream = AsyncChannel<String>()
private var results: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
searchBar.delegate = self
Task {
await observeSearches()
}
}
private func observeSearches() async {
// This reads like plain English!
for await query in searchStream
.debounce(for: .milliseconds(500))
.removeDuplicates() {
await performSearch(query: query)
}
}
private func performSearch(query: String) async {
// Simulate API call
try? await Task.sleep(nanoseconds: 500_000_000)
let mockResults = [
"Result for \(query) - 1",
"Result for \(query) - 2",
"Result for \(query) - 3"
]
await MainActor.run {
self.results = mockResults
self.tableView.reloadData()
}
}
}
extension AsyncSearchViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await searchStream.send(searchText)
}
}
}
Why this feels night‑and‑day
AsyncChannelacts as an async stream you cansendinto.- A single
for awaitloop consumes debounced, deduplicated values. - All code stays in the async/await world – no bridging required.
- No cancellables, no extra lifecycle management.
Breaking Down the Async Algorithms Approach
The magic line:
for await query in searchStream
.debounce(for: .milliseconds(500))
.removeDuplicates() {
await performSearch(query: query)
}
What’s happening?
| Component | Description |
|---|---|
AsyncChannel | A sendable async sequence. Think of it as a pipeline where you push values on one end and pull them on the other. |
.debounce(for: .milliseconds(500)) | Waits for 500 ms of “quiet time” before emitting the latest value. If a new value arrives before the timer fires, the timer resets. |
.removeDuplicates() | Suppresses consecutive identical queries, preventing unnecessary API calls. |
for await | Consumes the async sequence element‑by‑element, just like a regular for‑in loop. |
await performSearch(query:) | Calls the async search function directly—no Task {} wrapper needed. |
All of this runs on the Swift concurrency runtime, giving you deterministic cancellation, structured concurrency, and a syntax that reads like prose.
TL;DR
- Swift Async Algorithms provides collection‑style operators (
debounce,throttle,merge, …) for async sequences. - It lets you replace Combine‑style pipelines with clean
async/awaitcode. - No extra framework, no cancellables, no bridging—just native Swift concurrency.
Give it a try the next time you need debouncing, throttling, or any other stream‑processing logic in an async‑first codebase. Your future self (and your reviewers) will thank you.
Debounce – Emit the latest value after a pause in the stream:
for await value in stream.debounce(for: .seconds(0.5), clock: .continuous) {
print(value) // Fires only after the user stops typing for 0.5 s
}
If the user keeps typing during that window, the timer resets.
.removeDuplicates()
Filters out consecutive identical values.
If the user types “cat”, deletes it, then types “cat” again, the search runs only once.
for await
The loop suspends at each iteration, waiting for the next value.
Clean, sequential, and readable.
Installation
Add Swift Async Algorithms to your project via Swift Package Manager:
https://github.com/apple/swift-async-algorithms
In Xcode: File → Add Package Dependencies → paste the URL above.
When to Use Which?
Use Async Algorithms when:
- You’re already using
async/await - Building new features from scratch
- You want simpler, more readable code
- Your team is comfortable with modern Swift concurrency
Use Combine when:
- Working with legacy code that already uses Combine
- You need tight SwiftUI integration (Combine still has the edge here)
- Building complex reactive pipelines with many operators
- Targeting iOS versions before
async/await
The Memory Trick
- Combine = publishers push values → you subscribe and react
- Async Algorithms = you pull values → you await and process
One More Thing: Other Cool Operators
Throttle – Emit at most one value per time window
for await value in stream.throttle(for: .seconds(1), clock: .continuous) {
print(value)
}
Merge – Combine multiple async sequences
for await value in merge(streamA, streamB) {
print(value)
}
Zip – Combine values from multiple sequences pairwise
for await (a, b) in zip(streamA, streamB) {
print("Pair: \(a), \(b)")
}
CombineLatest – Get the latest from multiple streams
for await (a, b) in combineLatest(streamA, streamB) {
print("Latest: \(a), \(b)")
}
The Bottom Line
Swift Async Algorithms isn’t replacing Combine – it’s giving you a choice.
If you’ve gone all‑in on async/await (and you should), then Async Algorithms is the natural next step.
The search‑bar example shows it perfectly: what required multiple objects, subscriptions, and mental gymnastics in Combine becomes a single readable for await loop.
Next time you reach for Combine just to debounce a text field, try Async Algorithms instead.