Advanced Concurrency

flatMap vs switchToLatest

August 14, 2026
5 min read
Featured image for blog post: 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

OperatorBehaviorUse Case
flatMapKeep all aliveParallel operations where you want ALL results
switchToLatestCancel previousUser 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch