Swift concurrency makes asynchronous code easier to read, but readable syntax alone does not make a concurrent system reliable. Production code still needs clear task ownership, cancellation rules, isolation boundaries and deterministic state updates.
Design around ownership, not syntax
The first question is not “where can I add async?” It is “which object owns this work, and when should that work stop?” Every long-running task should have an owner: a screen, a view model, a repository, an application service or a background process.
When ownership is unclear, tasks outlive screens, duplicated requests race each other and stale responses overwrite newer state. A simple rule helps: the layer that starts a task should either await it directly or retain a handle that it can cancel.
Choose task boundaries deliberately
Avoid creating detached tasks as a default. Structured concurrency gives parent tasks responsibility for child work, propagates cancellation and makes failures easier to reason about.
func loadDashboard() async {
async let profile = profileRepository.fetchProfile()
async let bookings = bookingRepository.fetchUpcomingBookings()
do {
let (user, trips) = try await (profile, bookings)
state = .loaded(user: user, trips: trips)
} catch is CancellationError {
return
} catch {
state = .failed(message: error.localizedDescription)
}
}
Use parallel child tasks only when the operations are independent. If the second request depends on the first result, keep the flow sequential so the dependency is explicit.
Make cancellation part of the feature
Cancellation is a product behavior. Search requests should stop when the query changes, image loading should stop when a row disappears, and screen-level work should stop when the user leaves the flow.
@MainActor
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(350))
let results = try await repository.search(query)
try Task.checkCancellation()
state = .loaded(results)
} catch is CancellationError {
// Expected when the query changes.
} catch {
state = .failed(error.localizedDescription)
}
}
}
}
Cancellation is cooperative. Check for it before expensive work and before committing results that could now be stale.
Use actors for shared mutable state
Actors are useful when multiple tasks access state that must remain consistent, such as token refresh coordination, in-memory caches or request deduplication.
actor TokenVault {
private var refreshTask: Task<Token, Error>?
func validToken() async throws -> Token {
if let token = cachedToken, !token.isExpired { return token }
if let refreshTask { return try await refreshTask.value }
let task = Task { try await authClient.refreshToken() }
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
}
An actor is not a replacement for architecture. Keep its responsibility small and avoid turning one global actor into a hidden service locator.
Keep UI state on MainActor
View models that publish UI state should usually be isolated to @MainActor. This documents that state changes are serialized on the main actor and removes scattered dispatch calls.
Do not move all work onto the main actor. Networking, decoding and repository operations can remain asynchronous outside it. Only the state coordination that drives the interface needs main-actor isolation.
Bridge legacy callbacks carefully
Many SDKs still expose completion handlers. Wrap them once at an infrastructure boundary using checked continuations, then expose an async API to the rest of the app.
func authorize(_ request: PaymentRequest) async throws -> PaymentResult {
try await withCheckedThrowingContinuation { continuation in
gateway.authorize(request) { result in
continuation.resume(with: result)
}
}
}
Make sure every callback path resumes exactly once. If the underlying API supports cancellation, connect it to the task rather than leaving the legacy operation running.
Test concurrent flows deterministically
Concurrency tests become reliable when time, identifiers and dependencies are injectable. Prefer controllable fakes over real delays. A fake repository can suspend until the test explicitly releases it, allowing you to verify loading, cancellation and stale-response behavior.
- Test that a newer search cancels or ignores an older result.
- Test that cancellation does not become a visible error.
- Test actor-protected deduplication under simultaneous calls.
- Test UI state changes on the main actor.
Production checklist
- Every task has a clear owner and lifecycle.
- Cancellation is handled as an expected outcome.
- Shared mutable state is isolated behind a narrow boundary.
- UI-facing state is updated on
MainActor. - Detached tasks are rare and justified.
- Tests cover races, stale responses and repeated actions.
