DEV Community

Cover image for Debounce in Swift: Ditch Combine for This One Simple Loop
ArshTechPro
ArshTechPro

Posted on

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  1. Create a PassthroughSubject to emit search text
  2. Chain operators: debounceremoveDuplicatessink
  3. Bridge to async with Task inside sink
  4. Manage cancellables for cleanup
  5. 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The difference is night and day:

  1. Create an AsyncChannel (think of it as an async stream)
  2. Use a simple for await loop
  3. Chain operations right on the sequence: .debounce().removeDuplicates()
  4. Everything is async – no bridging needed
  5. 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)
}
Enter fullscreen mode Exit fullscreen mode

Here's what's happening:

  1. AsyncChannel: A sendable async sequence. Think of it as a pipeline where you send values from one end and receive them from the other.

  2. .debounce(for: .milliseconds(500)): Waits 500ms of "quiet time" before emitting the latest value. If new values arrive during that window, the timer resets.

  3. .removeDuplicates(): Filters out consecutive identical values. If the user types "cat", deletes it, then types "cat" again, we only search once.

  4. 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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Merge – Combine multiple async sequences:

for await value in merge(streamA, streamB) {
    print(value)
}
Enter fullscreen mode Exit fullscreen mode

Zip – Combine values from multiple sequences pairwise:

for await (a, b) in zip(streamA, streamB) {
    print("Pair: \(a), \(b)")
}
Enter fullscreen mode Exit fullscreen mode

CombineLatest – Get the latest from multiple streams:

for await (a, b) in combineLatest(streamA, streamB) {
    print("Latest: \(a), \(b)")
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
arshtechpro profile image
ArshTechPro

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
Enter fullscreen mode Exit fullscreen mode