Building responsive and reliable search in SwiftUI
Learn how to debounce search input, cancel obsolete requests and prevent stale results using Swift concurrency.
14 Sep 2026 · 6 min read
When building a search feature, we usually want results to update as the user types. To keep this interaction responsive and robust, we need to avoid sending unnecessary requests and ensure that only results for the current query reach the interface.
In this article, we'll use SwiftUI and Swift concurrency to debounce search input, cancel obsolete requests and prevent older responses from replacing newer results.

We'll build the solution in four steps:
- Connect the search task to the current query.
- Debounce the input before starting a request.
- Prevent obsolete requests from publishing stale results.
- Keep the loading and error states tied to the current search.
Step 1: Connecting the search task to the query
A first implementation might start a new task whenever the query changes:
struct SearchView: View {@State private var query = ""@State private var results: [SearchResult] = []let searchService: SearchServicevar body: some View {List(results) { result inText(result.title)}.searchable(text: $query).onChange(of: query) { _, newQuery inTask {results = try await searchService.search(for: newQuery)}}}}
This starts an independent task for every change. Suppose the user types "cat", pauses briefly, and then continues with "cats". The request for "cats" might finish first, while the slower request for "cat" finishes afterward and overwrites the newer results.
This is an ordering race between asynchronous operations. The problem is that the final value depends on which request finishes last rather than which query is current.
To avoid leaving these independent tasks running, we can use SwiftUI's task(id:) modifier. It gives the search operation a lifecycle tied to the query:
.task(id: query) {results = try await searchService.search(for: query)}
SwiftUI starts the task when the view appears. Whenever query changes, it cancels the previous task and starts a new one with the updated value. It also cancels the task when the view disappears.
Step 2: Debouncing the input
Without a debounce, every keystroke can start a request, even though the user hasn't finished entering the query yet. A debounce avoids this unnecessary work by waiting for a short period of inactivity before performing the search. We can implement it by sleeping at the start of the task:
.task(id: query) {do {try await Task.sleep(for: .milliseconds(300))results = try await searchService.search(for: query)} catch is CancellationError {// Expected when the query changes.} catch {// Handle the search error.}}
Task.sleep(for:) responds to cancellation by throwing CancellationError. If the user types another character during those 300 milliseconds, SwiftUI cancels the sleeping task before it reaches the request. Only after the user pauses does the search begin.
This gives us debounce behavior using the task lifecycle that SwiftUI already manages. If an app already uses Combine, its debounce operator remains a valid alternative, as shown in the article on debouncing a SwiftUI search query with Combine.
Step 3: Preventing stale results
When query changes, task(id:) requests cancellation of the previous task by marking it as cancelled. It doesn't forcibly terminate the task at that exact point. Cancellation in Swift is cooperative: the code running inside the task needs to notice the request and respond to it.
Task.sleep(for:) notices cancellation and throws, which is why it works for our debounce. Many system APIs, including URLSession's async methods, behave similarly. When search(for:) uses only cancellation-aware operations, it will throw and the old task won't reach the result assignment.
However, our search service might perform additional work, bridge an older callback API or catch a cancellation error internally. It could then return a value even though SwiftUI has already cancelled its task. Task.checkCancellation() provides an explicit checkpoint before we update the interface:
let newResults = try await searchService.search(for: submittedQuery)try Task.checkCancellation()results = newResults
Task.checkCancellation() reads the cancellation state set by task(id:) and throws if this task is obsolete. It makes sure that an earlier cancellation request is respected before the task publishes its result. If we know that the complete search implementation always propagates cancellation, this additional check is defensive rather than strictly necessary.
We can add a second, explicit guard by capturing the query associated with the request and comparing it with the current query before publishing the result:
let submittedQuery = querylet newResults = try await searchService.search(for: submittedQuery)try Task.checkCancellation()guard submittedQuery == query else { return }results = newResults
With task(id:) and a cancellation check, this comparison is usually redundant. It still documents an important rule at the point where shared UI state changes: a response may only update the results when it belongs to the current query. This is particularly useful if the surrounding task management changes later.
Step 4: Managing loading and error states
We can now combine empty-query handling, debouncing, cancellation and relevance checking in one method:
struct SearchView: View {@State private var query = ""@State private var results: [SearchResult] = []@State private var isLoading = false@State private var errorMessage: String?let searchService: SearchServicevar body: some View {Group {if isLoading {ProgressView()} else if let errorMessage {ContentUnavailableView("Search failed",systemImage: "exclamationmark.triangle",description: Text(errorMessage))} else {List(results) { result inText(result.title)}}}.searchable(text: $query).task(id: query) {await performSearch(for: query)}}private func performSearch(for rawQuery: String) async {let submittedQuery = rawQuery.trimmingCharacters(in: .whitespaces)isLoading = falseerrorMessage = nilguard !submittedQuery.isEmpty else {results = []return}do {try await Task.sleep(for: .milliseconds(300))isLoading = truelet newResults = try await searchService.search(for: submittedQuery)try Task.checkCancellation()guard submittedQuery == query.trimmingCharacters(in: .whitespaces) else {return}results = newResultsisLoading = false} catch is CancellationError {// A new task now owns the loading and result state.} catch {guard submittedQuery == query.trimmingCharacters(in: .whitespaces) else {return}errorMessage = error.localizedDescriptionisLoading = false}}}
We set isLoading only after the debounce finishes, so quickly typing doesn't flash a progress indicator for every character. At the beginning of each new task, we reset the loading and error state left by the previous query.
The cancellation handler deliberately doesn't change any UI state. By the time an old task observes cancellation, a newer task may already be responsible for that state. Letting the old task set isLoading to false, for example, could hide the progress indicator while the current request is still running.
Conclusion
Besides debouncing requests while the user is typing, responsive search requires us to control which asynchronous operation is allowed to update the interface.
By connecting the work to query with task(id:), using a cancellable sleep as a debounce and validating the response before publishing it, we can keep the implementation compact while protecting it from stale results.
The same approach works whenever input can change faster than asynchronous work completes, such as autocomplete suggestions, remote form validation or filters that load data from a server. We can tie the task to the changing input, cancel obsolete work and verify its relevance before updating the UI.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



