flatMap vs switchToLatest

flatMap and switchToLatest both handle "publisher of publishers"—but with very different behaviors. Getting this wrong causes bugs.
Follow along with the code: iOS-Practice on GitHub
The Scenario: Search
User types in a search field. Each keystroke triggers an API request.
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.map { query in
api.search(query) // Returns Publisher
}
// Now what? We have Publisher<Publisher<[Result]>>
flatMap: Keep All Requests
flatMap subscribes to every inner publisher. All requests complete.
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.map { query in api.search(query) }
.flatMap { $0 }
.sink { results in
self.results = results
}
Problem: If the user types "sw", then "swi", then "swift":
- Request for "sw" takes 500ms
- Request for "swi" takes 200ms
- Request for "swift" takes 800ms
Results arrive: "swi", "sw", "swift". The UI shows "sw" results even though user typed "swift"!
switchToLatest: Cancel Old, Use New
switchToLatest cancels the previous inner publisher when a new one arrives.
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.map { query in api.search(query) }
.switchToLatest()
.sink { results in
self.results = results // Always matches current search text
}
Now:
- User types "sw" → request starts
- User types "swi" → "sw" request cancelled, "swi" starts
- User types "swift" → "swi" cancelled, "swift" starts
- Only "swift" results arrive
Visual Comparison
flatMap: All inner publishers stay alive
Search: --"a"--------"ab"--------"abc"---->
Inner: \--A-results--/ \-AB-results-/ \-ABC-results->
Output: --------A-results---AB-results------ABC-results-->
^ could arrive out of order!
switchToLatest: Previous cancelled when new arrives
Search: --"a"--------"ab"--------"abc"---->
Inner: \--X (cancelled) \-X (cancelled) \-ABC-results->
Output: ------------------------------------------ABC-results->
^ only latest
When to Use Each
| Operator | Behavior | Use Case |
|---|---|---|
flatMap | Keep all alive | Parallel operations where you want ALL results |
switchToLatest | Cancel previous | User input, search, typeahead |
flatMap with maxPublishers
Limit concurrent operations:
userIdsPublisher
.flatMap(maxPublishers: .max(3)) { userId in
api.fetchUser(userId) // Max 3 concurrent requests
}
.collect()
.sink { allUsers in
// All users fetched, max 3 at a time
}
Complete Search Example
class SearchViewModel: ObservableObject {
@Published var searchText = ""
@Published var results: [SearchResult] = []
@Published var isSearching = false
private var cancellables = Set<AnyCancellable>()
init() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.handleEvents(receiveOutput: { [weak self] _ in
self?.isSearching = true
})
.map { [weak self] query -> AnyPublisher<[SearchResult], Never> in
guard let self, !query.isEmpty else {
return Just([]).eraseToAnyPublisher()
}
return self.api.search(query)
.replaceError(with: [])
.eraseToAnyPublisher()
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.results = results
self?.isSearching = false
}
.store(in: &cancellables)
}
}
Interview Tip
This is a common interview question: "How do you handle search with Combine?" The answer involves debounce + switchToLatest. Explain WHY switchToLatest—cancelling stale requests prevents race conditions.