Swift’s async/await syntax is deceptively simple. You sprinkle in some async, add a few awaits, and suddenly your code looks clean and sequential again. No more callback pyramids. No more completion handler gymnastics.
But here’s the thing—that simplicity hides a lot of complexity. And some patterns that look perfectly reasonable will silently cause performance issues, bugs, or crashes.
I’ve spent a lot of time migrating codebases from completion handlers to async/await, and I keep seeing the same mistakes. These aren’t beginner errors—they’re traps that catch experienced developers because the code compiles fine and often appears to work correctly.
Let’s walk through five patterns that seem right but aren’t, understand why they’re problematic, and look at how to fix them.
Pitfall #1: Assuming async Moves Work Off the Main Thread
This is probably the most common misconception. You mark a function async, call it from your view, and assume the heavy lifting happens somewhere in the background.
The reality is more nuanced—and has changed across Swift versions.
The Problematic Pattern
class ImageProcessor {
func processImage(_ image: UIImage) async -> UIImage {
// CPU-intensive image manipulation
let processed = applyExpensiveFilters(image)
return processed
}
private func applyExpensiveFilters(_ image: UIImage) -> UIImage {
// Imagine this takes 500ms of pure CPU work
// ... complex pixel manipulation ...
return image
}
}
// In your SwiftUI view or view controller
@MainActor
class ImageViewModel: ObservableObject {
@Published var processedImage: UIImage?
func process(_ image: UIImage) async {
// This looks like it should be fine, right?
processedImage = await imageProcessor.processImage(image)
}
}
Will this freeze your UI? It depends on your Swift version and project settings—which is exactly why this pitfall is so insidious.
Why This Is Confusing
The rules for where async functions execute have evolved:
Swift 5.5–5.6: Non-isolated async functions could “inherit” their caller’s executor in some cases, meaning work called from @MainActor might run on the main thread.
Swift 5.7+ (SE-0338): This was clarified—non-isolated async functions formally run on the global executor (a background thread). So in theory, processImage should run in the background.
Swift 6.2+ (Xcode 26): With default main actor isolation enabled, nonisolated functions inherit the caller’s actor by default. We’re back to main thread execution unless you explicitly opt out with @concurrent or nonisolated(nonsending).
But here’s the thing that catches people regardless of version: the synchronous code inside your async function has nowhere to suspend. Look at processImage—there’s no await inside it. It’s just synchronous code wrapped in an async function. Even if Swift schedules it on a background executor, that 500ms of CPU work runs uninterrupted wherever it starts.
And in SwiftUI specifically, views are @MainActor by default (as of Xcode 16). Methods on your view—and closures created within them—often inherit that isolation implicitly.
The Fix
Don’t rely on implicit executor behavior. Be explicit about where CPU-bound work runs:
class ImageProcessor {
func processImage(_ image: UIImage) async -> UIImage {
// Explicitly move to a background thread
await Task.detached(priority: .userInitiated) {
self.applyExpensiveFilters(image)
}.value
}
}
Or mark the function to explicitly run off the main actor:
class ImageProcessor {
// Swift 6.2+: @concurrent ensures this runs on the global executor
@concurrent
func processImage(_ image: UIImage) async -> UIImage {
applyExpensiveFilters(image)
}
// Pre-6.2: nonisolated async runs on global executor per SE-0338
nonisolated func processImageLegacy(_ image: UIImage) async -> UIImage {
applyExpensiveFilters(image)
}
}
Or use an actor to make isolation explicit:
actor ImageProcessor {
func processImage(_ image: UIImage) -> UIImage {
// Actor-isolated, definitely won't run on main actor
applyExpensiveFilters(image)
}
}
The key insight: async enables suspension—it doesn’t dictate where code runs. The execution context depends on actor isolation, Swift version, and project settings. When you have CPU-bound work, don’t leave it to chance. Be explicit.
Pitfall #2: Fire-and-Forget Tasks That Can’t Be Cancelled
When you need to kick off async work from a synchronous context, Task { } is the natural tool. But it’s easy to create tasks you can’t control.
The Problematic Pattern
class SearchViewModel: ObservableObject {
@Published var results: [SearchResult] = []
func search(_ query: String) {
Task {
let results = await searchService.search(query)
await MainActor.run {
self.results = results
}
}
}
}
Now imagine the user types quickly: “s”, “sw”, “swi”, “swif”, “swift”. You’ve just launched five concurrent search tasks. They might complete out of order. The results for “sw” might arrive after the results for “swift”, leaving your UI showing stale data.
And you have no way to cancel the outdated searches.
Why This Happens
Task { } creates an unstructured task. It starts immediately, runs independently, and you’ve thrown away the reference to it. The task will complete (or fail) eventually, but you have no handle to cancel it or even know when it’s done.
This is sometimes what you want. Often it isn’t.
The Fix
Store the task reference and cancel previous work:
class SearchViewModel: ObservableObject {
@Published var results: [SearchResult] = []
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
// Cancel any in-flight search
searchTask?.cancel()
searchTask = Task {
// Add debouncing if needed
try? await Task.sleep(for: .milliseconds(300))
// Check if we were cancelled during the sleep
guard !Task.isCancelled else { return }
let results = await searchService.search(query)
// Check again before updating UI
guard !Task.isCancelled else { return }
await MainActor.run {
self.results = results
}
}
}
deinit {
searchTask?.cancel()
}
}
For SwiftUI, the .task modifier handles this automatically—it cancels when the view disappears or when its identity changes:
struct SearchView: View {
@State private var query = ""
@State private var results: [SearchResult] = []
var body: some View {
List(results) { result in
// ...
}
.task(id: query) {
// Automatically cancelled when query changes
guard !query.isEmpty else { return }
results = await searchService.search(query)
}
}
}
The key insight: Unstructured tasks need lifecycle management. If you create it, you’re responsible for it.
Pitfall #3: Sequential await When You Want Concurrency
The async let syntax exists to run multiple async operations concurrently. But it’s easy to accidentally serialize work that should be parallel.
The Problematic Pattern
func loadDashboard() async -> Dashboard {
// These look concurrent, but...
async let user = fetchUser()
async let posts = fetchPosts()
async let notifications = fetchNotifications()
// This IS concurrent - all three are in flight
return Dashboard(
user: await user,
posts: await posts,
notifications: await notifications
)
}
Actually, that one’s fine. Here’s where people mess up:
func loadDashboard() async -> Dashboard {
// This is SEQUENTIAL - each one completes before the next starts
let user = await fetchUser()
let posts = await fetchPosts()
let notifications = await fetchNotifications()
return Dashboard(user: user, posts: posts, notifications: notifications)
}
If each fetch takes 200ms, the first version completes in ~200ms (parallel). The second takes ~600ms (sequential).
A Subtler Version of This Mistake
func loadDashboard() async -> Dashboard {
async let user = fetchUser()
// Awaiting immediately defeats the purpose
let resolvedUser = await user
async let posts = fetchPosts()
async let notifications = fetchNotifications()
return Dashboard(
user: resolvedUser,
posts: await posts,
notifications: await notifications
)
}
Now fetchUser completes before fetchPosts and fetchNotifications even start. You’ve got partial concurrency at best.
Why This Happens
async let starts the work immediately when the line executes. But await blocks until that specific value resolves. If you await before declaring other async let bindings, you’ve introduced a sequence point.
The concurrent work only happens between the async let declarations and their first await.
The Fix
Declare all your async let bindings together, then await them together:
func loadDashboard() async -> Dashboard {
// All three start immediately
async let user = fetchUser()
async let posts = fetchPosts()
async let notifications = fetchNotifications()
// All three are awaited together
return Dashboard(
user: await user,
posts: await posts,
notifications: await notifications
)
}
For dynamic numbers of concurrent operations, use TaskGroup:
func loadAllProfiles(_ userIDs: [UserID]) async -> [Profile] {
await withTaskGroup(of: Profile?.self) { group in
for id in userIDs {
group.addTask {
try? await fetchProfile(id)
}
}
var profiles: [Profile] = []
for await profile in group {
if let profile {
profiles.append(profile)
}
}
return profiles
}
}
The key insight: async let starts work eagerly. Structure your code so all concurrent work is declared before any of it is awaited.
Pitfall #4: Ignoring Cooperative Cancellation
Task cancellation in Swift is cooperative. When you cancel a task, it doesn’t stop immediately—it sets a flag that the task is expected to check periodically. If you never check, cancellation does nothing.
The Problematic Pattern
func processLargeDataset(_ items: [Item]) async -> [ProcessedItem] {
var results: [ProcessedItem] = []
for item in items {
// This could be thousands of items
let processed = await process(item)
results.append(processed)
}
return results
}
If someone cancels this task after processing 10 items, it will keep going through all remaining items. The cancellation flag is set, but nobody’s checking it.
Why This Happens
Swift doesn’t forcibly terminate cancelled tasks because that would be unsafe—you might be in the middle of writing to a file or holding a lock. Instead, cancellation is a request that your code should honor at appropriate points.
Some async functions (like Task.sleep and URLSession methods) check for cancellation and throw CancellationError. But your custom loops and processing code won’t unless you explicitly add those checks.
The Fix
Check for cancellation at logical points in long-running work:
func processLargeDataset(_ items: [Item]) async throws -> [ProcessedItem] {
var results: [ProcessedItem] = []
for item in items {
// Option 1: Check and throw
try Task.checkCancellation()
// Option 2: Check and return early
// guard !Task.isCancelled else { return results }
let processed = await process(item)
results.append(processed)
}
return results
}
The difference between the two approaches:
Task.checkCancellation()throws aCancellationError, unwinding the call stackTask.isCancelledlets you handle cancellation gracefully (return partial results, clean up, etc.)
For task groups, cancellation propagates to child tasks—but those child tasks still need to check:
func processLargeDataset(_ items: [Item]) async throws -> [ProcessedItem] {
try await withThrowingTaskGroup(of: ProcessedItem.self) { group in
for item in items {
// If the group is cancelled, this won't add more tasks
// But already-running tasks continue until they check
group.addTask {
try Task.checkCancellation()
return await process(item)
}
}
return try await group.reduce(into: []) { $0.append($1) }
}
}
The key insight: Cancellation is a request, not a command. Your code is responsible for checking and responding appropriately.
Pitfall #5: Unsafe Continuation Handling
When bridging callback-based APIs to async/await, withCheckedContinuation (or its throwing variant) is the standard tool. But continuations have a strict rule: they must be resumed exactly once.
The Problematic Pattern
func fetchData() async -> Data? {
await withCheckedContinuation { continuation in
legacyAPI.fetch { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure:
// Oops - forgot to resume!
// The caller hangs forever
print("Fetch failed")
}
}
}
}
Or the opposite problem:
func fetchData() async -> Data? {
await withCheckedContinuation { continuation in
legacyAPI.fetch { result in
if let data = result.data {
continuation.resume(returning: data)
}
// Bug: This might resume TWICE if data exists
if result.isComplete {
continuation.resume(returning: result.finalData)
}
}
}
}
Resuming twice is an immediate crash. Never resuming is a silent hang.
Why This Happens
Continuations are a low-level primitive. They’re the bridge between the callback world and the async/await world, and they don’t have guardrails. The checked variants add runtime assertions to help catch mistakes in debug builds, but they can’t save you from forgetting to resume entirely.
The Fix
Make continuation handling exhaustive and obvious:
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.fetch { result in
// Exhaustive switch - compiler helps ensure all cases handled
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
For APIs with multiple callbacks (progress, completion, failure), make sure only one path resumes:
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
var hasResumed = false
func resumeOnce(with result: Result<Data, Error>) {
guard !hasResumed else { return }
hasResumed = true
continuation.resume(with: result)
}
legacyAPI.fetch(
onSuccess: { data in resumeOnce(with: .success(data)) },
onFailure: { error in resumeOnce(with: .failure(error)) },
onCancel: { resumeOnce(with: .failure(CancellationError())) }
)
}
}
Also consider what happens if the callback is never called at all (API bug, network timeout without proper error handling, etc.). You might need timeouts or other safeguards.
The key insight: Continuations must be resumed exactly once. Make that invariant obvious in your code structure.
A Quick Mental Model
Here’s how I think about these pitfalls:
| Pitfall | Root Cause | Question to Ask |
|---|---|---|
| Main thread blocking | async doesn’t guarantee “background” | Am I explicit about where this code runs? |
| Uncontrolled tasks | Lost references | Can I cancel this if I need to? |
| Sequential async let | Premature awaiting | Are all concurrent operations declared before any await? |
| Ignored cancellation | Cooperative model | Where are my cancellation checkpoints? |
| Continuation misuse | Exactly-once requirement | Is every code path resuming exactly once? |
Wrapping Up
Swift concurrency is powerful, but it requires understanding what’s actually happening beneath the clean syntax. The patterns we’ve covered aren’t obvious from reading the code—they require knowing the execution model.
A few habits that help:
-
Be explicit about isolation. When you care where code runs, make it obvious with actors or explicit context switches.
-
Manage task lifecycles. If you create an unstructured task, know when and how it ends.
-
Think in terms of suspension points. Concurrent work happens between declaration and first await.
-
Check for cancellation. If your operation takes more than a moment, give it exit points.
-
Test your continuations. Make sure every callback path resumes exactly once.
The good news is that once you internalize these patterns, the code becomes predictable. The async/await syntax does make concurrent code cleaner—you just need to know where the sharp edges are.
Further reading: