# How to Migrate Completion Handlers to Async/Await Patterns in Swift

> Migrate Swift completion handlers to async/await. Learn to eliminate nested closures and leverage modern concurrency for cleaner code, cancellation, and error handling.

- Repository: [Thomas Ricouard/Skills](https://github.com/Dimillian/Skills)
- Tags: migration-guide
- Published: 2026-04-01

---

**Swift’s modern concurrency model replaces callback-based completion handlers with linear `async`/`await` code, eliminating nested closures while providing built-in cancellation, automatic error propagation, and clear actor isolation.**

Migrating completion handlers to async/await patterns in Swift transforms complex callback pyramids into readable, structured concurrency code. According to the Dimillian/Skills repository, this migration represents a core remediation action for Swift concurrency experts, bridging legacy callback APIs to modern SwiftUI view lifecycles. The repository provides concrete reference implementations demonstrating how view-scoped async functions replace callback-driven load operations.

## Architectural Advantages of Async/Await

Swift’s structured concurrency introduces three fundamental improvements over traditional completion-handler patterns.

**Linear, readable flow.** Asynchronous work expresses itself in straight-line execution order, removing the "pyramid of doom" created by deeply nested callbacks.

**Built-in cancellation and error propagation.** `Task` cancellation operates as a first-class concept, while errors unwind automatically through `try await`. This eliminates ad-hoc error-callback parameters and manual result unwrapping.

**Clear actor isolation.** By moving heavy computational work onto background `Task` instances (or dedicated `actor` types) and returning to the main actor only for UI updates, you maintain thread safety while keeping UI code responsive.

## The Five-Step Migration Strategy

As documented in [`swift-concurrency-expert/SKILL.md`](https://github.com/Dimillian/Skills/blob/main/swift-concurrency-expert/SKILL.md) (line 3), migrating completion handlers to async/await follows a systematic remediation path.

### 1. Identify the Callback API

Locate methods taking completion closures, typically signatured as `(Result<T, Error>) -> Void` or similar success/failure handler patterns. Marking these surfaces defines your migration boundary.

### 2. Create an Async Wrapper

Bridge legacy APIs using `withCheckedContinuation` or `withCheckedThrowingContinuation`. If the underlying API already supports async, simply forward the call.

### 3. Replace UI-Thread Calls with `.task`

In SwiftUI views, embed async calls inside `.task { await load() }` or `.task(id:)` for input-driven work. This ties async execution to the view lifecycle, guaranteeing cancellation when the view disappears. The reference implementation in [`swiftui-ui-patterns/references/async-state.md`](https://github.com/Dimillian/Skills/blob/main/swiftui-ui-patterns/references/async-state.md) (lines 24-28) demonstrates this pattern.

### 4. Handle Errors and Cancellation

Wrap async calls in `do { … } catch is CancellationError { … } catch { … }` blocks. This aligns with Swift’s error model and prevents spurious UI error states when tasks cancel naturally.

### 5. Update Callers

Remove completion arguments from call sites and invoke the new async functions using `await`. This final step completes the migration and dramatically simplifies consuming code.

## Bridging Legacy APIs with Continuations

Consider a typical networking function using completion handlers:

```swift
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: URL(string: "https://api.example.com/users/\(id)")!) { data, _, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let data = data,
              let user = try? JSONDecoder().decode(User.self, from: data) else {
            completion(.failure(MyError.decoding))
            return
        }
        completion(.success(user))
    }.resume()
}

```

Transform this into an async function using `withCheckedThrowingContinuation` to bridge the callback boundary:

```swift
func fetchUser(id: String) async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        fetchUser(id: id) { result in
            switch result {
            case .success(let user):
                continuation.resume(returning: user)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

```

This wrapper maintains compatibility with existing callback-based callers while enabling modern async consumption.

## Integrating with SwiftUI View Lifecycles

The [`swiftui-ui-patterns/references/async-state.md`](https://github.com/Dimillian/Skills/blob/main/swiftui-ui-patterns/references/async-state.md) file (lines 42-51) demonstrates how view-scoped async functions replace callback-driven load operations. Implement lifecycle-aware data loading using the `.task` modifier:

```swift
struct ProfileView: View {
    let userId: String
    @State private var user: User?
    @State private var error: Error?

    var body: some View {
        Group {
            if let user = user {
                Text("Hello, \(user.name)!")
            } else if let error = error {
                Text("Error: \(error.localizedDescription)")
            } else {
                ProgressView()
            }
        }
        .task {
            await load()
        }
    }

    private func load() async {
        do {
            user = try await fetchUser(id: userId)
        } catch is CancellationError {
            // Task was cancelled – nothing to do
        } catch {
            self.error = error
        }
    }
}

```

The `.task` modifier automatically cancels the async work when the view disappears, preventing stale data updates and memory leaks.

## Handling Cancellation and Background Work

For computationally intensive operations, explicitly check cancellation status and offload work from the main actor:

```swift
func heavyComputation(input: [Int]) async throws -> [Int] {
    try Task.checkCancellation()               // Fast-path cancellation check
    return try await Task.detached {
        // Intensive CPU work runs off the main actor
        input.map { heavyTransform($0) }
    }.value
}

```

Using `Task.detached` creates a separate top-level task running on a cooperative thread pool, while `Task.checkCancellation()` provides a lightweight exit point for cancelled operations.

## Summary

- **Swift concurrency eliminates callback pyramids** through linear `async`/`await` syntax, as identified in [`swift-concurrency-expert/SKILL.md`](https://github.com/Dimillian/Skills/blob/main/swift-concurrency-expert/SKILL.md).
- **Use `withCheckedThrowingContinuation`** to bridge legacy completion-handler APIs without rewriting underlying network or database layers.
- **Attach async work to SwiftUI lifecycles** using `.task` modifiers to ensure automatic cancellation and proper main-actor isolation.
- **Handle `CancellationError` distinctly** from other error types to avoid displaying cancellation events as user-facing errors.
- **Move heavy work off the main actor** using `Task.detached` or dedicated actors to maintain UI responsiveness.

## Frequently Asked Questions

### When should I use `withCheckedContinuation` versus `withUnsafeContinuation`?

Use `withCheckedThrowingContinuation` (or `withCheckedContinuation` for non-throwing APIs) when bridging legacy callback code to async/await. The checked variant provides runtime validation that your continuation executes exactly once, catching logic errors during development. Only use `withUnsafeContinuation` in performance-critical paths where you have manually verified single-resumption guarantees and need to eliminate runtime checks.

### How do I handle cancellations during async migrations?

Check for `CancellationError` explicitly in your `catch` blocks, as shown in the SwiftUI example from [`swiftui-ui-patterns/references/async-state.md`](https://github.com/Dimillian/Skills/blob/main/swiftui-ui-patterns/references/async-state.md). When a view disappears or a parent task cancels, Swift throws `CancellationError` up the async chain. Suppress user-facing error UI for this specific error type while allowing other errors to surface normally.

### Can I migrate incrementally, or must I convert the entire codebase at once?

Swift concurrency supports incremental adoption. Create async wrappers around existing completion-handler APIs using continuations, then migrate call sites individually. The `fetchUser` example demonstrates this approach: the original callback API remains functional while new code consumes the `async throws` variant. This strategy minimizes risk while modernizing large codebases systematically.

### What replaces `DispatchQueue.main.async` when updating UI from async tasks?

Mark UI-updating functions with `@MainActor` or use `await MainActor.run { … }` to hop back to the main thread. When using `.task` in SwiftUI, the modifier automatically executes the async closure on the main actor, so state updates within the task body (like assigning to `@State` properties) occur safely without explicit thread hopping.