You type “camera,” decide that is too broad, and replace it with “camera bag.” The second search finishes first. A moment later, the list jumps back to cameras.
That bug is easy to miss on office Wi-Fi. It becomes obvious on a train, behind a slow proxy, or whenever one backend request takes an unusual route through the cache.
Adding a debounce reduces the number of requests. Cancelling the previous task reduces wasted work. Neither, by itself, establishes which response is allowed to change the screen.
The useful invariant is simple: every visible search state belongs to the current search intent. Results, empty messages, failures, and loading indicators all follow the same rule. Let’s build that invariant into a small SwiftUI screen, then test the awkward timing deliberately.
This matters even when your transport cancels reliably. A search screen has two different responsibilities: asking a service to stop spending resources, and deciding whether its output still represents the user’s intent. Keeping those responsibilities separate makes the feature easier to reason about when you later add caching, offline fallback, or a different networking client.
1. Give SwiftUI the task lifetime, not the correctness policy
SwiftUI’s task modifier with an ID cancels and restarts work when that ID changes. It can also cancel the task when the view disappears. This is a good fit for a search screen whose work should follow its current input and visibility.
The cancellation part is important, but Swift uses cooperative cancellation. Cancelling a task does not forcibly unwind arbitrary code. A callback adapter might ignore cancellation, a response might already be available, or your own service might catch an error and return cached data.
There is another detail worth designing around: editing the binding and running the replacement task are separate events. Do not depend on SwiftUI cancelling the old task at the exact instant your setter runs. Invalidate its authority to publish in that setter instead.
These are logical ordering problems, not unsynchronized memory access. @MainActor serializes the model’s state changes, but an async method can suspend while other main-actor work changes the query. Our actor reentrancy guide explains the same distinction in a shared-download cache.
2. Make every edit a new search intent
We will search a catalog of products. The example targets iOS 17 or later because it uses Observation; the task and search modifiers themselves predate that requirement. The code uses Swift 6 language mode.
First, define the values and dependency boundary:
import Foundation
struct Product: Identifiable, Codable, Equatable, Sendable {
let id: String
let name: String
}
struct SearchRequest: Equatable, Sendable {
let id: UUID
let query: String
}
enum SearchPhase: Equatable {
case idle
case waiting
case loading
case results([Product])
case empty
case failed(String)
}
protocol ProductSearching: Sendable {
func search(_ query: String) async throws -> [Product]
}
A phase enum avoids contradictory flags: an error cannot accidentally appear alongside an unrelated “no results” message. The request holds the normalized query and a fresh identity. Comparing query strings would fail for A → B → A: the first A response belongs to an abandoned search, even though the text matches again.

Here is the whole model:
import Observation
@MainActor
@Observable
final class SearchModel {
typealias Debounce = @Sendable () async throws -> Void
private(set) var query = ""
private(set) var request = SearchRequest(id: UUID(), query: "")
private(set) var phase: SearchPhase = .idle
private let service: any ProductSearching
private let debounce: Debounce
init(
service: any ProductSearching,
debounce: @escaping Debounce = {
try await Task.sleep(for: .milliseconds(300))
}
) {
self.service = service
self.debounce = debounce
}
func setQuery(_ text: String) {
guard text != query else { return }
query = text
request = SearchRequest(
id: UUID(),
query: text.trimmingCharacters(in: .whitespacesAndNewlines)
)
phase = request.query.isEmpty ? .idle : .waiting
}
func run(_ candidate: SearchRequest) async {
guard isCurrent(candidate), !candidate.query.isEmpty else { return }
phase = .waiting
do {
try await debounce()
guard isCurrent(candidate) else { return }
phase = .loading
let products = try await service.search(candidate.query)
guard isCurrent(candidate) else { return }
phase = products.isEmpty ? .empty : .results(products)
} catch {
guard isCurrent(candidate) else { return }
// Some dependencies can report cancellation independently.
if error is CancellationError ||
(error as? URLError)?.code == .cancelled {
phase = .idle
return
}
phase = .failed("Search failed. Please try again.")
}
}
private func isCurrent(_ candidate: SearchRequest) -> Bool {
candidate.id == request.id && !Task.isCancelled
}
}
setQuery has no suspension point. Updating the text, replacing the identity, and replacing the phase happen together on the main actor. An old completion cannot interleave halfway through that method. Before the next task even starts, the old identity is already invalid.
Notice where the guards live: before the initial state change, after debouncing, after fetching, and inside the error handler. Protecting only the results assignment would still allow an obsolete failure to overwrite a newer list. Likewise, an unconditional defer { isLoading = false } could hide the spinner for a replacement request.
This example deliberately clears previous results on every edit. Keeping them visible can be a valid product choice, but label them as belonging to the previous query. Do not silently present old results as the answer to new input.
We invalidate on every raw text change, even if trimming produces the same query. That is a conservative policy: typing a trailing space starts another debounce interval. You can choose to treat equivalent normalized input as the same intent, but make that decision in the synchronous setter. If account, locale, or filters changed too, equivalent text must not accidentally reuse their older request.
The initial request is empty, so appearing on this screen does not immediately hit the network. Clearing the field also changes identity and moves directly to .idle; it does not need to wait for the debounce to show the search prompt. This is a small UX detail with a useful consequence: the state reset follows the user’s gesture, not the availability of an executor.
For more on storing this model in SwiftUI, see modern state management with Observation. Observation tracks the UI dependencies; it does not supply request ordering for you.
3. Connect the binding before starting the task
The search binding must go through setQuery. Using a freely writable query property and invalidating the request only inside .task would reopen the timing gap we just closed.
import SwiftUI
@MainActor
struct ProductSearchView: View {
@State private var model: SearchModel
init(service: any ProductSearching) {
_model = State(initialValue: SearchModel(service: service))
}
var body: some View {
let request = model.request
NavigationStack {
content
.navigationTitle("Catalog")
.searchable(text: Binding(
get: { model.query },
set: { model.setQuery($0) }
), prompt: "Search products")
}
.task(id: request.id) {
await model.run(request)
}
}
@ViewBuilder
private var content: some View {
switch model.phase {
case .idle:
ContentUnavailableView(
"Search the catalog", systemImage: "magnifyingglass"
)
case .waiting:
ContentUnavailableView(
"Waiting for your query", systemImage: "ellipsis"
)
case .loading:
ProgressView("Searching…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .results(let products):
List(products) { product in
Text(product.name)
}
case .empty:
ContentUnavailableView(
"No products found", systemImage: "magnifyingglass"
)
case .failed(let message):
ContentUnavailableView(
"Couldn't search", systemImage: "exclamationmark.triangle",
description: Text(message)
)
}
}
}
Capturing request in body makes the task’s input agree with its ID. The async closure does not read a newer query after it starts and accidentally attach that query to an older task identity.
There is no additional Task {} inside the modifier. Awaiting the model directly keeps the work attached to the task SwiftUI manages. An unstructured task you start and forget would need its own lifetime and cancellation management.
On reappearance, SwiftUI can run the task again for the current intent. Our cancellation guard prevents a cancelled task from publishing, even if its dependency eventually returns. This model belongs to one screen; if several screens share it, design separate request ownership instead of having them run the same intent concurrently.
For an interactive search field, the waiting state should stay visually quiet. The sample uses an explicit message so you can see each transition; a real design might use a subtle placeholder instead. A prominent spinner for every keystroke can make a fast screen feel busy. Show progress when the request actually starts, and keep accessibility announcements focused on useful changes rather than every intermediate character.
4. Debounce to save work, then measure the real bottleneck
A 300-millisecond delay is a starting point, not a universal search setting. Tune it for your product: a local lookup may need no delay, while a costly remote query may benefit from a minimum query length and a longer pause.
The production debounce uses Task.sleep, which suspends rather than blocks a thread and throws if cancelled before the delay ends. Rapid edits therefore replace waiting tasks before they reach the service. The guard after the delay also protects against an injected or custom debounce that does not honor cancellation.
Debouncing is not a guarantee that only one network operation exists. Once requests have started, slow or cancellation-ignoring work may overlap. Identity makes those completions harmless to this screen; service-level cancellation and resource limits still matter for bandwidth and server load.
Do not replace the delay with Thread.sleep or a semaphore wait. This method is main-actor isolated, and blocking its thread would freeze the very typing interaction you are trying to improve. Nor does raising the task priority make a slow backend fast; priority affects scheduling, not request validity or the server’s response time.
Here is a concrete service for an endpoint returning a JSON array of products:
actor CatalogService: ProductSearching {
private let endpoint: URL
private let session: URLSession
init(endpoint: URL, session: URLSession = .shared) {
self.endpoint = endpoint
self.session = session
}
func search(_ query: String) async throws -> [Product] {
guard var components = URLComponents(
url: endpoint, resolvingAgainstBaseURL: false
) else { throw URLError(.badURL) }
var items = components.queryItems ?? []
items.removeAll { $0.name == "q" }
items.append(URLQueryItem(name: "q", value: query))
components.queryItems = items
guard let url = components.url else { throw URLError(.badURL) }
let (data, response) = try await session.data(from: url)
try Task.checkCancellation()
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([Product].self, from: data)
}
}
Configure the endpoint from your app rather than embedding a pretend production URL. Authentication, pagination, response-size limits, and service-specific error mapping belong at this boundary too.
The service is an actor so synchronous decoding is not performed on the model’s main actor. Large JSON payloads can still occupy its executor and delay its other requests; profile and limit those payloads. Merely spelling a function async does not move synchronous work off the UI actor, as our async UI-freezing guide discusses.
5. Test the ordering instead of hoping the network reproduces it
The most valuable test is not “a search returns products.” It is “an obsolete response arrives at the worst possible moment and changes nothing.”
Use a service that reports when a call has registered and lets the test resolve each call explicitly. This fake intentionally ignores cancellation, exercising the model’s independent publication policy. The continuations are test-only gates: every registered call must be completed before the test exits.
import Testing
actor ControlledSearch: ProductSearching {
private var count = 0
private var pending: [Int: CheckedContinuation<[Product], Error>] = [:]
private var observers: [(Int, CheckedContinuation<Void, Never>)] = []
func search(_ query: String) async throws -> [Product] {
try await withCheckedThrowingContinuation { continuation in
count += 1
pending[count] = continuation
let ready = observers.filter { $0.0 <= count }
observers.removeAll { $0.0 <= count }
ready.forEach { $0.1.resume() }
}
}
func waitForCall(_ number: Int) async {
if count >= number { return }
await withCheckedContinuation { observers.append((number, $0)) }
}
func complete(_ number: Int, with result: Result<[Product], Error>) {
guard let continuation = pending.removeValue(forKey: number) else {
preconditionFailure("Complete only a registered, unfinished call")
}
continuation.resume(with: result)
}
func callCount() -> Int { count }
}
enum OldResponse: Sendable, CaseIterable {
case products, empty, failure
var result: Result<[Product], Error> {
switch self {
case .products: .success([Product(id: "old", name: "Old result")])
case .empty: .success([])
case .failure: .failure(URLError(.timedOut))
}
}
}
@Test(arguments: OldResponse.allCases, ["new", ""])
@MainActor
func oldResponseCannotPublishBeforeReplacementStarts(
response: OldResponse, nextQuery: String
) async {
let service = ControlledSearch()
let model = SearchModel(service: service, debounce: {})
model.setQuery("old")
let old = model.request
let task = Task { await model.run(old) }
await service.waitForCall(1)
model.setQuery(nextQuery)
let expected: SearchPhase = nextQuery.isEmpty ? .idle : .waiting
#expect(model.phase == expected)
// No replacement task has started, and we did not cancel the old one.
await service.complete(1, with: response.result)
await task.value
#expect(model.phase == expected)
}
@Test @MainActor
func returningToSameQueryDoesNotReviveOldRequest() async {
let service = ControlledSearch()
let model = SearchModel(service: service, debounce: {})
model.setQuery("camera")
let first = model.request
let oldTask = Task { await model.run(first) }
await service.waitForCall(1)
model.setQuery("bag")
model.setQuery("camera")
let latest = model.request
#expect(first.id != latest.id)
let newTask = Task { await model.run(latest) }
await service.waitForCall(2)
let products = [Product(id: "new", name: "Current camera")]
await service.complete(2, with: .success(products))
await newTask.value
await service.complete(1, with: .success([]))
await oldTask.value
#expect(model.phase == .results(products))
}
The first test runs all six combinations: old products, empty results, and errors, each arriving after either a replacement query or clearing the field. Crucially, it does not cancel the old task. That proves synchronous invalidation works before SwiftUI’s task lifecycle catches up.
Awaiting task.value matters here. Resuming the fake service’s continuation only makes the model eligible to resume; it does not prove that the model has processed the result yet. Waiting for the task to finish establishes that the rejected completion has actually passed through the guard before the assertion. Without that handshake, a test could pass simply because the bad state update had not happened yet.
The second reproduces A → B → A and resolves the latest request first. A query-string guard would incorrectly accept the first request’s empty result.
To test the debounce boundary itself, replace elapsed time with an explicit gate:
actor ManualDebounce {
private var pending: CheckedContinuation<Void, Never>?
private var observers: [CheckedContinuation<Void, Never>] = []
func wait() async {
await withCheckedContinuation { continuation in
precondition(pending == nil)
pending = continuation
observers.forEach { $0.resume() }
observers.removeAll()
}
}
func waitUntilEntered() async {
if pending != nil { return }
await withCheckedContinuation { observers.append($0) }
}
func release() {
precondition(pending != nil)
pending?.resume()
pending = nil
}
}
@Test @MainActor
func cancelledDebounceNeverStartsService() async {
let service = ControlledSearch()
let gate = ManualDebounce()
let model = SearchModel(service: service, debounce: { await gate.wait() })
model.setQuery("camera")
let request = model.request
let task = Task { await model.run(request) }
await gate.waitUntilEntered()
#expect(model.phase == .waiting)
#expect(await service.callCount() == 0)
task.cancel()
model.setQuery("")
await gate.release()
await task.value
#expect(await service.callCount() == 0)
#expect(model.phase == .idle)
}
This single-waiter gate is deliberately smaller than a general-purpose virtual clock. It establishes that a cancelled task cannot cross the debounce boundary even when the debounce dependency itself ignores cancellation. It does not test the real clock’s duration; that is the standard library’s responsibility.
Put the model and tests in the same test target, or import your model module with @testable import. Give the test suite a runner-level timeout so a broken registration handshake fails rather than hanging forever. No arbitrary sleep or repeated Task.yield is needed. Our Swift Testing guide covers organizing async and parameterized cases.
6. Keep the ownership rule as the feature grows
Adding filters, sort order, or an account switch means those values become part of search intent too. Snapshot them into the request and synchronously issue a new identity whenever they change. Do not let a task assemble its parameters from mutable model properties after suspension.
For a retry button, keep the text but create a fresh identity and reset the phase. Calling setQuery with identical text intentionally does nothing here, so retry needs its own explicit method. Pagination needs a related policy: pages may append only to the active base search, and repeated page requests need their own ordering or deduplication.
Avoid logging raw search text by default. Queries can contain names, addresses, or other personal information. Request identities, durations, cancellation counts, and response sizes often provide enough diagnostic context without collecting the input itself.
Key Takeaways
- Invalidate the old request in the binding setter, before the replacement task starts.
- Use a fresh identity for each search intent; identical text can represent different requests.
- Debounce reduces work, cancellation requests that work stop, and identity decides who can publish.
- Guard loading, results, empty states, and errors with the same ownership check.
- Test response order with explicit gates, including clearing the field and A → B → A.
A reliable search screen is less about a clever debounce operator and more about a clear answer to one question: does this completion still own the screen? Make that answer explicit, and slow responses stop being surprising.
