- 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
// 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.
| GCD | async/await | |
|---|---|---|
| Available since | 2009 | Swift 5.5 |
| Style | Imperative: you pick the queue | Structured: the runtime schedules |
| Thread safety | Your discipline (serial queues, barriers) | Compiler help (actors) |
| Synchronous access | Yes (queue.sync) | No, actor access is await |
| Cancellation | Manual | Built 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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
@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:
DispatchQueue.global().async {
Task {
let result = try await asyncOperation()
DispatchQueue.main.async {
self.update(with: result)
}
}
}
Wrapping GCD in async:
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
- Concurrency in The Swift Programming Language
- Dispatch and Sendable on Apple Developer
- SE-0306 Actors and SE-0317 async let
// Frequently_Asked
// Continue_Learning
withCheckedContinuation in Swift: How It Works (vs Unsafe)
withCheckedContinuation turns a completion handler into async/await. Resume it exactly once; the checked version catches misuse the unsafe one ignores.
Async/Await Basics in Swift
A practical introduction to async/await in Swift, covering the fundamentals you need to write concurrent code that's both safe and readable.
Resource Isolation in Swift Using GCD
Grand Central Dispatch remains a practical choice for thread safety in Swift, especially when you need synchronous access or are working with legacy code.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.