Introduction
If you're building iOS apps in 2024, you've probably encountered both Combine and async/await, and wondered which one to reach for. The landscape shifted significantly when Swift Concurrency arrived in Swift 5.5, giving us async/await and AsyncSequence as native language features. In this guide, we'll explore Combine vs async/await: a practical guide for developers who need to make real decisions about which tool fits the job.
The core question isn't which is "better"—it's which solves your problem with the least friction. We've learned this the hard way across production systems handling everything from user authentication flows to real-time notifications.
async/await for request/response work
When you need to fetch data, make an API call, or perform a single async operation that completes with one result, async/await is your answer. It reads like synchronous code, which makes it easier to reason about, debug, and maintain.
Here's a typical request/response scenario:
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
return user
}
// Call it naturally
let user = try await fetchUser(id: "123")
This is straightforward: request goes out, response comes back, we parse it and return. The code flows top-to-bottom without nested closures or publishers. Error handling uses standard try/catch.
In a real SwiftUI view, you'd use this with a task modifier:
struct UserDetailView: View {
@State var user: User?
@State var isLoading = false
@State var error: Error?
var body: some View {
VStack {
if let user = user {
Text(user.name)
Text(user.email)
} else if isLoading {
ProgressView()
} else if let error = error {
Text("Error: \(error.localizedDescription)")
}
}
.task {
isLoading = true
do {
user = try await fetchUser(id: "123")
} catch {
self.error = error
}
isLoading = false
}
}
}
The .task modifier handles cancellation automatically when the view disappears, which prevents common memory leaks and race conditions we used to debug with Combine subscriptions.
AsyncSequence for streams
When you need to handle multiple values over time—like a stream of location updates, a WebSocket connection, or real-time events—async/await has AsyncSequence. It's the natural successor to Combine's Publisher, but built into the language.
Consider monitoring device location:
func monitorLocation() -> AsyncStream {
AsyncStream { continuation in
let manager = CLLocationManager()
manager.delegate = LocationDelegate(continuation: continuation)
manager.startUpdatingLocation()
}
}
And consuming it:
@State var locations: [CLLocationCoordinate2D] = []
var body: some View {
List(locations, id: \.hashValue) { location in
Text("\(location.latitude), \(location.longitude)")
}
.task {
for await location in monitorLocation() {
locations.append(location)
}
}
}
The for-await-in loop is cleaner than Combine's sink or @StateObject publisher subscriptions. You get cancellation for free—when the task is cancelled (view disappears), the loop exits and resources clean up.
For more complex stream logic—filtering, transforming, combining multiple streams—you might use AsyncSequence extension methods:
func filteredLocations() -> AsyncStream {
monitorLocation()
.filter { location in
// Only emit if accuracy improved
location.horizontalAccuracy < 10
}
.map { location in
// Round coordinates for privacy
CLLocationCoordinate2D(
latitude: (location.latitude * 1000).rounded() / 1000,
longitude: (location.longitude * 1000).rounded() / 1000
)
}
}
Combine still works for existing code
We're not saying throw out Combine entirely. If you have a mature codebase built on Combine, migrating everything is expensive and risky. Combine is battle-tested, stable, and handles complex scenarios well. Leave it where it works.
The pragmatic approach: new features and new code use async/await. When you touch existing Combine code, small improvements are fine, but don't rewrite working systems unless there's a concrete benefit.
One practical hybrid approach is wrapping Combine publishers for async/await use:
extension Publisher {
func values() -> AsyncThrowingSequence {
AsyncThrowingSequence { continuation in
var cancellable: AnyCancellable?
cancellable = self
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
continuation.finish()
case .failure(let error):
continuation.finish(throwing: error)
}
cancellable?.cancel()
},
receiveValue: { value in
continuation.yield(value)
}
)
}
}
}
// Now you can iterate existing publishers
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [User].self, decoder: JSONDecoder())
for try await user in publisher.values() {
// Handle each user
}
This lets you keep Combine infrastructure while consuming it with modern Swift concurrency syntax.
New code mostly uses Swift Concurrency
In production systems we've built over the past year, we've made a clear decision: all new async work uses async/await and AsyncSequence. Here's why it matters:
- Language-level support: Compiler understands async/await and catches misuse at compile time. With Combine, missing @escaping or incorrect threading can hide until runtime.
- Simpler mental model: async/await reads like normal code. Combine's declarative graph approach is powerful but takes longer to reason through, especially under pressure.
- Built-in cancellation: Task cancellation is automatic in SwiftUI views, with @Environment(\.dismiss) and task modifiers handling lifecycle. Combine subscriptions require manual cleanup.
- Ecosystem alignment: Apple's frameworks are migrating. Swift's standard library, Foundation APIs, and even some internal Apple SDKs now expose async/await interfaces first.
In a recent e-commerce project, we replaced a Combine-heavy product search flow with async/await. The new implementation was about 40% fewer lines, easier to test (because you can use regular XCTest without special publisher testing utilities), and team onboarding was faster—junior developers didn't need to learn Combine concepts first.
Common mistakes
Mistake 1: Blocking the main thread with async/await
async/await doesn't guarantee main-thread safety. If you fetch data on a background thread and update UI state without explicitly switching to the main thread, you'll crash or get race conditions:
// WRONG
let data = try await fetchData() // Runs on random thread
state = data // UI update off main thread
// RIGHT
let data = try await fetchData()
await MainActor.run {
state = data
}
// Or simpler in SwiftUI
@MainActor
func updateState() async {
state = try await fetchData()
}
Mistake 2: Forgetting to handle cancellation
Even with automatic task cancellation in SwiftUI, you need to respect it. If you're in a long loop or waiting for something, check Task.isCancelled:
for await item in stream {
if Task.isCancelled { break }
// Process item
}
Mistake 3: Mixing Combine and async/await poorly
Don't create unnecessary bridges. If you're wrapping a Combine publisher just to use async/await syntax, and you only call it once, stick with the original interface or use .values() conversion, not a custom wrapper.
Mistake 4: Using AsyncStream for one-shot operations
AsyncStream is for repeated values. For single async results, just use async functions and await them. Don't over-engineer.
Key takeaways
- Use async/await for request/response operations where you make a single async call and get one result back.
- Use AsyncSequence and AsyncStream for handling multiple values over time, replacing Combine's Publisher in new code.
- Keep Combine in existing production code where it works; don't rewrite for the sake of it, but migrate incrementally when touching that code.
- New iOS projects should default to Swift Concurrency. Only reach for Combine if you have a specific need (complex operator chains, specific timing guarantees, or deep existing investment).
- Remember threading: async/await doesn't guarantee main thread; use @MainActor or explicit MainActor.run() for UI updates.
- Leverage automatic cancellation in SwiftUI with .task() modifiers and respect Task.isCancelled in loops.
- Test async/await code with standard XCTest; the syntax is more straightforward than testing Combine publishers.






Comments
Loading comments…