← Back to list

Building a Debounced Autocomplete in Swift with Combine and MapKit

A “Latest Wins” pattern you can unit-test and ship with confidence 🚀

Srikanthvelaga · 2025-10-17 17:00 · 0 claps · 3.6 min read
#swift #debounce #mapkit #swiftui #latency
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

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:

  1. Debounce the user input.
  2. Cancel stale searches when a new term comes in.
  3. 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

  1. You can keep your method signature as-is func searchStream(input:) -> AnyPublisher<[MKLocalSearchCompletion], Error> and still achieve modern, reactive behavior.
  2. “Latest wins” doesn’t always require switchToLatest() — you can enforce it inside your service.
  3. Combine gives you debouncing, cancellation, and error handling out-of-the-box.
  4. 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