Skip to Content
Course content

89: Combine vs async/await: When to Use Which

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember a developer on my team a few years ago who was building a real-time search feature for a massive product catalog. He had just learned async/await and was enamored with how clean it made the code look. He tried to implement the search by triggering an async function every time the user typed a character. The result? A chaotic mess of race conditions where an older, slower network request would return after a newer one, overwriting the search results with stale data. He spent two days trying to manually manage Task handles and cancellation flags before he came to me, frustrated that the "modern" way felt so fragile.

The problem wasn't that async/await is flawed; it's that he was trying to use a hammer to fix a plumbing problem. He was treating a continuous stream of events as a series of isolated, one-off tasks.

The Linear Path vs. The Continuous Stream

The easiest way to decide between these two is to ask yourself: "Am I waiting for a single value, or am I observing a sequence of values over time?"

async/await is designed for linear flow. You call a function, you suspend, and eventually, you get a result (or an error) back. It's perfect for things like fetching a user profile, uploading a photo, or reading a file from disk. It turns asynchronous code into something that reads like a story: first do this, then do that, then show the result. If your logic is "Give me this one thing," async/await is almost always the right choice. It's more readable, easier to debug, and has much lower overhead.

Combine, on the other hand, is for "streams." Think of a stream as a pipe where data drops in at unpredictable intervals. A search bar is a classic example: the user types 'A', then 'Ap', then 'App'. That's not three separate, unrelated requests; it's one continuous stream of input. Combine gives you the tools to manipulate that pipe. You can debounce the input (wait for the user to stop typing for 300ms), filter out short strings, and switchToLatest to automatically cancel the previous network request when a new character is typed.

When Combine is Still the Only Tool for the Job

Even in a world where async/await is the star, Combine remains essential for reactive state management. If you're building a complex UI where five different components need to react instantly to a single change in a data model, trying to coordinate that with async functions becomes a nightmare of manual callbacks.

I often suggest sticking with Combine when you need to:

  • Combine multiple asynchronous sources into one (e.g., waiting for both a GPS lock and a network connection before starting a process).
  • Throttle or debounce high-frequency events (like scroll offsets or keystrokes).
  • Bind a data source directly to a UI element using @Published and assign(to:).

Bridging the Two Worlds

You don't actually have to choose one for your entire project. In fact, the best architecture usually mixes them. You might use Combine to handle the "messy" UI input and then trigger an async function to do the actual heavy lifting of the network request.

// Using Combine to handle the stream, then bridging to async/await
$searchText
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()
    .sink { [weak self] text in
        Task {
            // Bridge into the linear world of async/await
            do {
                self?.results = try await self?.searchAPI(query: text)
            } catch {
                self?.handleError(error)
            }
        }
    }
    .store(in: &cancellables)

Notice how the "noise" of the user's typing is handled by the Combine pipeline, but the actual data fetch—which is a simple "one-and-done" request—is handled by async/await. This is where you get the best of both worlds: the power of reactive streams and the readability of structured concurrency.




📋 Practical Task

Exercise: Implementing a Debounced Search Pipeline

You are tasked with fixing a "stuttering" search feature in a City Finder app. Currently, the app makes a network request on every single keystroke, which is overloading the API and causing the UI to flicker.

Your Goal: Create a CitySearchViewModel that uses Combine to monitor a searchText property and only triggers a search when the user has stopped typing for 500 milliseconds.

Requirements:

  • Use a @Published string for the search text.
  • Use debounce to prevent rapid-fire API calls.
  • Use removeDuplicates so the search doesn't re-run if the user types a character and then immediately deletes it.
  • Inside the sink, trigger a mock async function called fetchCities(query: String).
  • Ensure the subscription is stored in a Set<AnyCancellable> to prevent it from being deallocated immediately.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.