Logo for tanaschita.com

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.

Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE

We'll build the solution in four steps:

  1. Connect the search task to the current query.
  2. Debounce the input before starting a request.
  3. Prevent obsolete requests from publishing stale results.
  4. 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: SearchService
var body: some View {
List(results) { result in
Text(result.title)
}
.searchable(text: $query)
.onChange(of: query) { _, newQuery in
Task {
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 = query
let 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: SearchService
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let errorMessage {
ContentUnavailableView(
"Search failed",
systemImage: "exclamationmark.triangle",
description: Text(errorMessage)
)
} else {
List(results) { result in
Text(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 = false
errorMessage = nil
guard !submittedQuery.isEmpty else {
results = []
return
}
do {
try await Task.sleep(for: .milliseconds(300))
isLoading = true
let newResults = try await searchService.search(for: submittedQuery)
try Task.checkCancellation()
guard submittedQuery == query.trimmingCharacters(in: .whitespaces) else {
return
}
results = newResults
isLoading = 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.localizedDescription
isLoading = 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.

Sponsorship logo
Preparing for a technical iOS job interview
Preparing for a technical iOS Job Interview with over 300 questions & answers. Covering Swift & Objective-C, SwiftUI & UIKit, Combine, HTTP Networking, iOS File System, Core Data, Concurrency with async/await, Security, Automated Testing, Dependency Management, AI & Machine Learning and more.
LEARN MORE
Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE
Sponsorship logo
Become a sponsor of tanaschita.com
By publishing an article on different iOS topics every week, tanaschita.com is constantly growing in the developer community and may provide a great audience for you as a sponsor.
CLICK TO LEARN MORE

Newsletter

Image of a reading marmot
Subscribe

Like to support my work?

Say hi

Related tags

Articles with related topics

activitykit

widgetkit

swiftui

swift

ios

Getting started with Live Activities in SwiftUI

Build a public transport journey tracker for the Lock Screen and Dynamic Island with ActivityKit.

07 Sep 2026 · 7 min read

Latest articles and tips

© 2026 tanaschita.com

Privacy policy

Impressum