BS
BleepingSwift
Published on
Updated Nov 2, 2026
Tested on Xcode 27.1
10 min read
Intermediate

> Async/Await vs GCD in Swift: When to Use Each

Share:

// What_You_Will_Learn

  • Compare async/await and GCD approaches to concurrency
  • Understand when actors are better than serial queues
  • Use structured concurrency for safer parallel operations
  • Interoperate between async/await and legacy GCD code

Use async/await (Swift 5.5+) as the default for new concurrent code, especially networking, I/O and UI updates with @MainActor, and keep Grand Central Dispatch for synchronous thread-safe access, precise queue control (QoS classes, target queues, barriers), and correct legacy code that isn't worth rewriting. Async/await gives you compiler-checked isolation and structured cancellation; GCD gives you explicit control over where and when code runs.

GCDasync/await
Available since2009Swift 5.5
StyleImperative: you pick the queueStructured: the runtime schedules
Thread safetyYour discipline (serial queues, barriers)Compiler help (actors)
Synchronous accessYes (queue.sync)No, actor access is await
CancellationManualBuilt in (Task cancellation)

The sections below compare them with code.

The Fundamental Difference

GCD is imperative. You explicitly tell it where and when to run code:

Swift
func fetchUserGCD(completion: @escaping (Result<User, Error>) -> Void) {
    DispatchQueue.global().async {
        do {
            let data = try self.networkCall()
            let user = try JSONDecoder().decode(User.self, from: data)
            DispatchQueue.main.async {
                completion(.success(user))
            }
        } catch {
            DispatchQueue.main.async {
                completion(.failure(error))
            }
        }
    }
}

Async/await is declarative. You describe what should happen, and the runtime figures out the threading:

Swift
func fetchUser() async throws -> User {
    let data = try await networkCall()
    return try JSONDecoder().decode(User.self, from: data)
}

The async version is shorter and clearer. More importantly, the compiler understands it. Errors propagate with throw. Results return directly. No callbacks, no result types, no dispatch calls.

Thread Safety: Compiler Help vs. Discipline

GCD provides tools for thread safety, but using them correctly is your responsibility:

Swift
class CounterGCD {
    private let queue = DispatchQueue(label: "counter")
    private var _value = 0

    var value: Int {
        queue.sync { _value }
    }

    func increment() {
        queue.sync { _value += 1 }
    }
}

This works, but nothing stops you from accessing _value directly and creating a race condition. The compiler won't warn you.

Actors, async/await's solution to shared mutable state, provide compile-time enforcement:

Swift
actor Counter {
    private var value = 0

    func getValue() -> Int {
        value
    }

    func increment() {
        value += 1
    }
}

Accessing an actor's methods from outside requires await, and the compiler enforces this. You can't accidentally forget synchronization.

Swift
let counter = Counter()
await counter.increment()  // Must await
let current = await counter.getValue()

Synchronous vs. Asynchronous Access

Here's where GCD still has an advantage. It can provide synchronous thread-safe access:

Swift
final class Cache: @unchecked Sendable {
    private let queue = DispatchQueue(label: "cache")
    private var storage: [String: Any] = [:]

    subscript(key: String) -> Any? {
        get { queue.sync { storage[key] } }
        set { queue.sync { storage[key] = newValue } }
    }
}

// Usage: no await needed
let cache = Cache()
cache["user"] = user
let retrieved = cache["user"]

Notice the @unchecked Sendable. Under Swift 6 strict concurrency the compiler refuses to let Cache cross into concurrent code without it, because it has no way to see that the serial queue protects storage. @unchecked is you promising the compiler "trust me". That one annotation is the whole GCD trade-off in miniature: the safety is real, but only you can vouch for it.

Actors require async access, which can ripple through your codebase:

Swift
actor ActorCache {
    // Values must be Sendable to cross the actor boundary.
    private var storage: [String: any Sendable] = [:]

    func get(_ key: String) -> (any Sendable)? {
        storage[key]
    }

    func set(_ key: String, value: any Sendable) {
        storage[key] = value
    }
}

// Usage: await required
let cache = ActorCache()
await cache.set("user", value: user)
let retrieved = await cache.get("user")

The first version of this cache stored [String: Any], the same as the GCD one. It doesn't compile in Swift 6: "non-Sendable 'Any?'-typed result can not be returned from actor-isolated instance method 'get' to main actor-isolated context". An actor protects its own state, so anything handed out across its boundary has to be safe to share. Switching to any Sendable (or a generic Value: Sendable) fixes it, and it is a constraint the GCD version never imposes.

For hot paths where you can't afford async overhead, or contexts where async isn't available (like property getters), GCD remains the practical choice.

Structured vs. Unstructured Concurrency

GCD's concurrency is unstructured. You dispatch work and hope it completes:

Swift
func loadDashboardGCD(completion: @escaping (Dashboard) -> Void) {
    let group = DispatchGroup()
    var user: User?
    var posts: [Post]?
    var notifications: [Notification]?

    group.enter()
    fetchUser { result in
        user = try? result.get()
        group.leave()
    }

    group.enter()
    fetchPosts { result in
        posts = try? result.get()
        group.leave()
    }

    group.enter()
    fetchNotifications { result in
        notifications = try? result.get()
        group.leave()
    }

    group.notify(queue: .main) {
        completion(Dashboard(user: user!, posts: posts!, notifications: notifications!))
    }
}

You manage the group manually. Miss a leave() call and you hang. Call it twice and you crash.

Async/await offers structured concurrency where child tasks are tied to their parent:

Swift
func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()
    async let posts = fetchPosts()
    async let notifications = fetchNotifications()

    return try await Dashboard(
        user: user,
        posts: posts,
        notifications: notifications
    )
}

The three fetches run concurrently. If any throws, the others are cancelled automatically. When the function returns, all child tasks are complete. No manual coordination needed.

Cancellation

GCD has no built-in cancellation. You can use DispatchWorkItem and check its isCancelled property, but it's opt-in:

Swift
final class WorkItemRef: @unchecked Sendable {
    var item: DispatchWorkItem?
}

let ref = WorkItemRef()
let workItem = DispatchWorkItem {
    for i in 0..<1000 {
        guard ref.item?.isCancelled == false else { return }
        process(i)
    }
}
ref.item = workItem

DispatchQueue.global().async(execute: workItem)
workItem.cancel()  // Sets isCancelled, doesn't stop execution

The box is not decoration. A DispatchWorkItem closure can't reference the work item it's being used to create, so the obvious version (guard !workItem.isCancelled) fails to compile. Every GCD cancellation loop ends up with some variant of this indirection.

Async/await has cooperative cancellation built in:

Swift
func processItems() async throws {
    for i in 0..<1000 {
        try Task.checkCancellation()
        await process(i)
    }
}

let task = Task { try await processItems() }
task.cancel()  // Causes checkCancellation to throw

More importantly, when you cancel a parent task, child tasks inherit cancellation. Built-in async functions like URLSession.data(from:) respect cancellation automatically.

When to Use GCD

GCD remains the right choice in several scenarios.

When you need synchronous thread-safe access, actors can't help. If you're building a cache or configuration store that many places read synchronously, use GCD:

Swift
final class Config {
    static let shared = Config()
    private let queue = DispatchQueue(label: "config")
    private var values: [String: Any] = [:]

    subscript(key: String) -> Any? {
        queue.sync { values[key] }
    }
}

When maintaining legacy code, converting a large codebase to async/await is significant work. If the GCD code is correct and well-tested, there may be no practical benefit to rewriting it.

When you need precise queue control, GCD's quality of service classes, target queues, and barriers give you fine-grained control that async/await abstracts away. For performance-critical code where you need to control exactly where and how work executes, GCD gives you that power.

When to Use Async/Await

Async/await is the better default for new code.

For network requests and I/O, async/await eliminates callback hell and makes error handling natural:

Swift
do {
    let user = try await api.fetchUser()
    let posts = try await api.fetchPosts(for: user)
    let enriched = try await enrich(posts)
    await display(enriched)
} catch {
    await showError(error)
}

For UI code, @MainActor makes main thread dispatch automatic:

Swift
@MainActor
class ViewModel: ObservableObject {
    @Published var data: [Item] = []

    func refresh() async {
        do {
            data = try await fetchItems()  // UI update happens on main
        } catch {
            data = []
        }
    }
}

For complex concurrent operations, structured concurrency with async let and task groups is safer and more readable than dispatch groups.

Mixing Both

The good news: they interoperate well. You can call async code from GCD contexts and vice versa.

Calling async from GCD:

Swift
DispatchQueue.global().async {
    Task {
        let result = try await asyncOperation()
        DispatchQueue.main.async {
            self.update(with: result)
        }
    }
}

Wrapping GCD in async:

Swift
func legacyOperation() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        legacyAPI.fetch { data, error in
            if let error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: data!)
            }
        }
    }
}

This lets you adopt async/await incrementally, wrapping legacy APIs as you go.

What the Swift 6 Compiler Caught

I built every snippet in this article into one sample app with Xcode 27.1, Swift 6 language mode and SWIFT_STRICT_CONCURRENCY = complete, targeting the iOS 26 simulator. Three of the original examples failed to compile, and each failure says something about the two models.

The actor cache returned Any?, which isn't Sendable, so the compiler rejected it at the actor boundary. GCD never asks this question. Actors do, every time.

The GCD Cache and Config classes needed @unchecked Sendable before they could be used from concurrent code. The queue makes them safe, but the compiler can't prove it, so you sign for it yourself.

The view model's refresh() called try await inside a function that doesn't throw. That's an ordinary mistake, but it's the kind async/await surfaces at compile time, where a completion handler would have quietly dropped the error.

None of these would show up in a GCD-only codebase on Swift 5. They appear the moment you turn on strict checking, which is a good reason to flip that setting before you start mixing the two models rather than after.

The Bottom Line

For new code in 2026, default to async/await. The compiler safety, cleaner syntax, and structured concurrency make it the better choice for most tasks. Reserve GCD for the specific cases where you need synchronous access or fine-grained queue control.

For existing codebases, migrate opportunistically. Wrap completion-handler APIs in async interfaces, adopt actors where data isolation is complex, but don't rewrite working GCD code just because newer tools exist. The pragmatic path is using both where each makes sense.

Further Reading

// Frequently_Asked

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.