Building a Debounced Autocomplete in Swift with Combine and MapKit
A “Latest Wins” pattern you can unit-test and ship with confidence 🚀
Building a Debounced Autocomplete in Swift with Combine and MapKit
A “Latest Wins” pattern you can unit-test and ship with confidence 🚀
When building an autocomplete search in Swift — like when using Apple Maps’ MKLocalSearchCompleter — the real challenge isn’t fetching results.
It’s keeping your UI responsive, avoiding duplicate queries, and making sure old results don’t override new ones when users type quickly.
In this post, we’ll build a Combine-based autocomplete pipeline that’s:
- ✅ Debounced (no over-firing requests)
- ✅ Cancels stale searches automatically (“latest wins”)
- ✅ Clean, testable, and production-ready
- ✅ Pure Combine (no async/await required)
🧩 The Problem
Typing “Pizza Hut” quickly sends multiple requests:
P → Pi → Piz → Pizz → Pizza → Pizza H → ...
Without control, each of these fires a network or MapKit search. Soon you have overlapping results — or worse, outdated suggestions appearing after newer ones.
We need to:
- Debounce the user input.
- Cancel stale searches when a new term comes in.
- Propagate errors cleanly via Combine’s
.failure.
⚙️ The Architecture
Let’s break it down:
ViewModel — Builds the Combine pipeline (debounce + duplicates + mapping).
Service — Talks to MKLocalSearchCompleter (or any API).
Tests — Verify debounce, latest-wins, and error handling.
🧱 1. Service Contract
import Combine
import MapKit
protocol AutocompleteServicing {
func search(term: String) -> AnyPublisher<[MKLocalSearchCompletion], Error>
}
This allows dependency injection — the ViewModel doesn’t care if it’s backed by MapKit or a mock.
🚀 2. ViewModel (Combine Pipeline)
Here’s the reactive heart of our autocomplete logic 👇
We’ll keep it exactly as written — no need for fancy switchToLatest() hacks.
import Combine
import MapKit
final class AutocompleteViewModel {
private let service: AutocompleteServicing
init(service: AutocompleteServicing) {
self.service = service
}
func searchStream(
input: AnyPublisher<String, Never>
) -> AnyPublisher<[MKLocalSearchCompletion], Error> {
input
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { [service] term in
service.search(term: term)
}
.eraseToAnyPublisher()
}
}
✅ Debounce: waits for typing pauses. ✅ removeDuplicates: ignores the same term twice. ✅ flatMap: streams multiple searches (we’ll make it act like “latest wins” next).
🧠 3. MKCompleterService (Production Implementation)
This is where the “latest wins” magic happens.
We wrap MKLocalSearchCompleter in a Combine publisher.
When a new term arrives, we finish the previous subject immediately — canceling old results.
import Combine
import MapKit
final class MKCompleterService: NSObject, AutocompleteServicing {
private let completer: MKLocalSearchCompleter
private var current: (term: String, subject: PassthroughSubject<[MKLocalSearchCompletion], Error>)?
private let queue = DispatchQueue.main
override init() {
self.completer = MKLocalSearchCompleter()
super.init()
self.completer.delegate = self
}
func search(term: String) -> AnyPublisher<[MKLocalSearchCompletion], Error> {
// Finish previous request
current?.subject.send(completion: .finished)
let subject = PassthroughSubject<[MKLocalSearchCompletion], Error>()
current = (term: term, subject: subject)
queue.async {
self.completer.queryFragment = term
}
return subject.eraseToAnyPublisher()
}
}
extension MKCompleterService: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
guard let current = current,
current.term == completer.queryFragment else { return }
current.subject.send(completer.results)
current.subject.send(completion: .finished)
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
current?.subject.send(completion: .failure(error))
current = nil
}
}
💡 Every new search(term:) cancels the last one by completing its subject,
so even though our ViewModel uses flatMap, the behavior is effectively “switchToLatest.”
🧪 4. Mocks for Testing
Simple and flexible 👇
final class MockAutocompleteService: AutocompleteServicing {
var behavior: (String) -> AnyPublisher<[MKLocalSearchCompletion], Error> =
{ _ in Just([MKLocalSearchCompletion()])
.setFailureType(to: Error.self)
.eraseToAnyPublisher() }
func search(term: String) -> AnyPublisher<[MKLocalSearchCompletion], Error> {
behavior(term)
}
}
🧩 5. Unit Tests
Let’s prove it works — debounce, failure, duplicates, and latest-wins.
import XCTest
import Combine
import MapKit
final class AutocompleteViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
private func advanceRunLoop(by seconds: TimeInterval) {
RunLoop.main.run(until: Date().addingTimeInterval(seconds))
}
func testSuccessAfterDebounce() {
let service = MockAutocompleteService()
let vm = AutocompleteViewModel(service: service)
let input = PassthroughSubject<String, Never>()
var values: [[MKLocalSearchCompletion]] = []
let exp = expectation(description: "Value emitted")
vm.searchStream(input: input.eraseToAnyPublisher())
.sink(
receiveCompletion: { _ in },
receiveValue: { v in values.append(v); exp.fulfill() }
)
.store(in: &cancellables)
input.send("pizza")
advanceRunLoop(by: 1.1) // cross debounce
wait(for: [exp], timeout: 2)
XCTAssertEqual(values.count, 1)
}
func testFailurePropagates() {
let service = MockAutocompleteService()
service.behavior = { _ in Fail(error: URLError(.badServerResponse)).eraseToAnyPublisher() }
let vm = AutocompleteViewModel(service: service)
let input = PassthroughSubject<String, Never>()
let exp = expectation(description: "Stream failed")
vm.searchStream(input: input.eraseToAnyPublisher())
.sink(
receiveCompletion: { if case .failure = $0 { exp.fulfill() } },
receiveValue: { _ in XCTFail("Should not emit values") }
)
.store(in: &cancellables)
input.send("oops")
advanceRunLoop(by: 1.1)
wait(for: [exp], timeout: 2)
}
func testRemoveDuplicatesBlocksSameTerm() {
let service = MockAutocompleteService()
let vm = AutocompleteViewModel(service: service)
let input = PassthroughSubject<String, Never>()
var count = 0
let exp = expectation(description: "One emission")
exp.expectedFulfillmentCount = 1
vm.searchStream(input: input.eraseToAnyPublisher())
.sink(
receiveCompletion: { _ in },
receiveValue: { _ in count += 1; exp.fulfill() }
)
.store(in: &cancellables)
input.send("same")
advanceRunLoop(by: 1.1)
input.send("same") // duplicate ignored
advanceRunLoop(by: 1.1)
wait(for: [exp], timeout: 2)
XCTAssertEqual(count, 1)
}
}
✅ Tests are deterministic — no timers, just advancing the main run loop past the 1-second debounce.
⚖️ Why This Design Is Optimized
GoalHow It’s AchievedDebouncingdebounce(for:scheduler:)Duplicate suppressionremoveDuplicates()Latest-winsOld publishers finished inside the serviceError handlingCombine .failure completionTestabilityMockable service + deterministic timing
You get a fully reactive pipeline with no state stored in your ViewModel — the publisher expresses everything declaratively.
💬 Key Takeaways
- You can keep your method signature as-is
func searchStream(input:) -> AnyPublisher<[MKLocalSearchCompletion], Error>and still achieve modern, reactive behavior. - “Latest wins” doesn’t always require
switchToLatest()— you can enforce it inside your service. - Combine gives you debouncing, cancellation, and error handling out-of-the-box.
- This pattern is both production-ready and unit-test-friendly.
✨ Wrapping Up
We’ve built a lightweight, elegant Combine solution for autocomplete:
- Fully compatible with SwiftUI or UIKit
- Requires no async/await migration
- Clean separation of concerns
Next time you build a debounced search bar, use this as your foundation. It’s concise, reactive, and predictable — exactly what good Swift code should be. 💪
메타데이터
- post_id
- ef2f35ae519d
- slug
- building-a-debounced-autocomplete-in-swift-with-combine-and-mapkit-ef2f35ae519d
- url
- https://medium.com/@srikanthvelaga55/building-a-debounced-autocomplete-in-swift-with-combine-and-mapkit-ef2f35ae519d
- canonical_url
- https://medium.com/@srikanthvelaga55/building-a-debounced-autocomplete-in-swift-with-combine-and-mapkit-ef2f35ae519d
- author_url
- https://medium.com/@srikanthvelaga55
- status
- ok
- fetched_at
- 2026-07-16 16:28:36