- Published on
> Async/Await vs GCD in Swift: When to Use Each
- Authors

- Name
- Mick MacCallum
- @0x7fs
Swift offers two major concurrency systems: Grand Central Dispatch (GCD), which has been around since 2009, and async/await, introduced in Swift 5.5. They solve similar problems but with different philosophies. Understanding their trade-offs helps you pick the right tool.
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:
class Cache {
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"]
Actors require async access, which can ripple through your codebase:
actor ActorCache {
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
storage[key]
}
func set(_ key: String, value: Any) {
storage[key] = value
}
}
// Usage - await required
let cache = ActorCache()
await cache.set("user", value: user)
let retrieved = await cache.get("user")
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:
let workItem = DispatchWorkItem {
for i in 0..<1000 {
guard !workItem.isCancelled else { return }
process(i)
}
}
DispatchQueue.global().async(execute: workItem)
workItem.cancel() // Sets isCancelled, doesn't stop execution
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 {
data = try await fetchItems() // UI update happens on main
}
}
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.
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.
// Continue_Learning
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.
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.
Choosing the Right Resource Isolation Strategy in Swift
Swift offers actors, GCD, and locks for thread safety. Each solves the same problem differently. Here's how to choose.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.