Concurrency Deep Dive

Building Responsive Search with Debouncing

February 6, 2026
5 min read
Featured image for blog post: Building Responsive Search with Debouncing

User types "swift". Your app fires searches for "s", "sw", "swi", "swif", "swift". Five API calls for one query. Let's fix that.

Follow along with the code: iOS-Practice on GitHub

The Naive Approach

Here's what most tutorials show:

struct SearchView: View {
    @State private var searchResults: [String] = []
    @State private var searchText = ""
    @State private var currentTask: Task<Void, Never>?

    var body: some View {
        TextField("Search...", text: $searchText)
            .onChange(of: searchText) { _, newValue in
                performSearch(query: newValue)
            }
        // ...
    }

    func performSearch(query: String) {
        currentTask?.cancel()

        currentTask = Task {
            let delay = Double.random(in: 0.5...2.0)
            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))

            let results = await fetchResults(for: query)

            await MainActor.run {
                searchResults = results
            }
        }
    }
}

Two problems:

  1. No debounce: Every keystroke fires a search
  2. Race condition: Results can arrive out of order

If "swift" returns before "swif" (random delays), you'll show stale results.

Adding Debounce

Wait for the user to stop typing before searching:

func performSearch(query: String) {
    currentTask?.cancel()

    guard !query.isEmpty else {
        searchResults = []
        return
    }

    currentTask = Task {
        // Debounce: wait 300ms for user to stop typing
        try await Task.sleep(nanoseconds: 300_000_000)

        // If we get here, user stopped typing
        let results = await fetchResults(for: query)

        await MainActor.run {
            searchResults = results
        }
    }
}

When the user types a new character:

  1. Cancel the previous task
  2. Start a new task with 300ms delay
  3. If cancelled during the delay (user typed again), the task stops
  4. If the delay completes, fetch results

Key insight: Task.sleep throws CancellationError when cancelled. By using try instead of try?, the task exits immediately when cancelled.

Handling the Race Condition

Even with debouncing, results can arrive out of order. User searches "cat", then "dog". If "dog" returns first and "cat" returns second, you show cat results for a dog search.

Fix: Check if the query is still current before updating UI:

func performSearch(query: String) {
    currentTask?.cancel()

    guard !query.isEmpty else {
        searchResults = []
        return
    }

    currentTask = Task {
        try await Task.sleep(nanoseconds: 300_000_000)

        // Check cancellation before network call
        try Task.checkCancellation()

        let results = await fetchResults(for: query)

        // Check cancellation before updating UI
        try Task.checkCancellation()

        await MainActor.run {
            searchResults = results
        }
    }
}

Now if the user types a new query while results are fetching, the old task is cancelled and won't update the UI.

The Complete Pattern

struct SearchView: View {
    @State private var searchResults: [String] = []
    @State private var searchText = ""
    @State private var isSearching = false
    @State private var currentTask: Task<Void, Never>?

    var body: some View {
        VStack {
            TextField("Search...", text: $searchText)
                .textFieldStyle(.roundedBorder)
                .onChange(of: searchText) { _, newValue in
                    performSearch(query: newValue)
                }

            if isSearching {
                ProgressView()
            }

            List(searchResults, id: \.self) { result in
                Text(result)
            }
        }
    }

    func performSearch(query: String) {
        currentTask?.cancel()

        guard !query.isEmpty else {
            searchResults = []
            isSearching = false
            return
        }

        isSearching = true

        currentTask = Task {
            do {
                // Debounce
                try await Task.sleep(nanoseconds: 300_000_000)

                // Fetch
                let results = await fetchResults(for: query)

                // Update UI if not cancelled
                try Task.checkCancellation()
                await MainActor.run {
                    searchResults = results
                    isSearching = false
                }
            } catch {
                // Task was cancelled - do nothing
                // Next search will handle isSearching
            }
        }
    }

    func fetchResults(for query: String) async -> [String] {
        // Your actual API call here
        try? await Task.sleep(nanoseconds: 500_000_000)
        return (1...5).map { "\(query) result \($0)" }
    }
}

Why This Works

  1. Debounce: 300ms sleep before searching
  2. Cancellation: Previous task cancelled on new input
  3. Race prevention: checkCancellation() before UI update
  4. Clean errors: Cancellation caught and ignored (it's expected)

Tuning the Debounce

300ms is a common default. Adjust based on:

  • API cost: Expensive APIs might warrant 500ms
  • User expectation: Autocomplete feels sluggish above 400ms
  • Typing speed: Fast typists benefit from longer debounce

Interview Note

This pattern demonstrates understanding of:

  • Task cancellation (cooperative model)
  • Race conditions in async code
  • User experience considerations

If asked "how would you implement search?", walk through each problem and its solution. The naive approach, why it breaks, and how debounce + cancellation fix it.

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