Debounce in Swift: Ditch Combine for This One Simple Loop

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 textChain operators:
debounce→removeDuplicates→sinkBridge to async with
TaskinsidesinkManage 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 awaitloopChain 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.
For more details visit
Want to dive deeper? Check out Apple's official documentation:




