Remember when you first learned about debouncing in iOS? You probably reached for Combine, set up a publisher chain with debounce(), and felt like you'd unlocked some secret knowledge. But what if I told you there's a cleaner, more Swift-y way to do it?
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 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."
The one-line summary: It's like the Combine framework, but built for async/await instead of publishers and subscribers.
Why Should You Care?
Let me paint a picture: You're building a search bar. Every time the user types, you want to search an API, but you don't want to spam it with requests. You need debouncing.
The old way (Combine):
- Import a whole framework
- Deal with publishers, subscribers, and cancellables
- Store subscription lifecycles
- Convert between async and publisher worlds
The new way (Async Algorithms):
- Use native Swift concurrency
- Write code that reads like English
- No subscription management
- Works seamlessly with async/await
Plus, if you're already using async/await in your app, why add Combine just for one operator?
The Real-World Example: Search Bar Debounce
Let's build the same search feature in both approaches. You'll instantly see why Async Algorithms feels like a breath of fresh air.
The Combine Way
import Combine
import UIKit
class CombineSearchViewController: UIViewController {
@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var tableView: UITableView!
private var cancellables = Set<AnyCancellable>()
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 here:
- Create a
PassthroughSubjectto emit search text - Chain operators:
debounce→removeDuplicates→sink - Bridge to async with
Taskinsidesink - Manage cancellables for cleanup
- Deal with two different concurrency models
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)
}
}
}
The difference is night and day:
- Create an
AsyncChannel(think of it as an async stream) - Use a simple
for awaitloop - Chain operations right on the sequence:
.debounce().removeDuplicates() - Everything is async – no bridging needed
- No subscription management, no cancellables
Breaking Down the Async Algorithms Approach
Let's zoom in on the magic line:
for await query in searchStream.debounce(for: .milliseconds(500)).removeDuplicates() {
await performSearch(query: query)
}
Here's what's happening:
AsyncChannel: A sendable async sequence. Think of it as a pipeline where you send values from one end and receive them from the other..debounce(for: .milliseconds(500)): Waits 500ms of "quiet time" before emitting the latest value. If new values arrive during that window, the timer resets..removeDuplicates(): Filters out consecutive identical values. If the user types "cat", deletes it, then types "cat" again, we only search once.for await: The loop suspends at each iteration, waiting for the next value. Clean, sequential, 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 already using Combine
- Need tight SwiftUI integration (Combine still has the edge here)
- Building complex reactive pipelines with 10+ operators
- Targeting iOS versions before async/await
The Memory Trick
Here's how I remember it:
Combine = publishers push values → you subscribe and react
Async Algorithms = you pull values → you await and process
One More Thing: Other Cool Operators
Debounce isn't the only trick up Async Algorithms' sleeve. Here are a few favorites:
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 took 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.
Want to dive deeper? Check out Apple's official documentation:
Top comments (1)
Use Async Algorithms when: