GCD vs Swift Concurrency (async/await, actors) — when do you reach for which?
GCD hasn't disappeared and async/await didn't replace it entirely — a senior engineer knows which concurrency model fits a given queue, race condition, or legacy API.
LEARN BY DOINGPut your intuition to work3 steps
GCD vs Swift Concurrency (async/await, actors) — when do you reach for which?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Two concurrency models, one codebase
Most real iOS codebases today contain both Grand Central Dispatch, the C-based queue API that's been there since iOS 4, and Swift Concurrency — async/await, structured tasks, and actors — introduced with Swift 5.5 and iOS 15. An interviewer asking "when do you reach for which" wants to know you've actually shipped code in a codebase mid-migration, not just read the WWDC session.
#GCD, briefly
GCD hands you queues: DispatchQueue.main for the UI thread, DispatchQueue.global(qos:) for background work at a given quality-of-service, and custom serial or concurrent queues you create yourself. Work is submitted .async (fire and forget, return immediately) or .sync (block the caller until it finishes — dangerous on the same queue you're already running on, a classic deadlock). DispatchGroup lets you fan out several async pieces of work and get notified when they all finish; DispatchSemaphore gates access to a limited resource or bridges callback-based code into a blocking wait.
#Where GCD gets uncomfortable
None of GCD's building blocks are compiler-enforced structure — a DispatchQueue.global().async { } block can outlive the object that started it, nothing propagates cancellation automatically, and shared mutable state accessed from multiple queues still needs a lock or a serial queue you remember to route every access through, by convention rather than by compiler check. Callback-based GCD code that composes several async steps degenerates into nested closures — the classic "callback pyramid" — and every one of those closures is a candidate retain-cycle site if it captures self strongly.
#Swift Concurrency's answer
async/await lets asynchronous code read top-to-bottom like synchronous code, without the closure nesting. A Task is a structured unit of async work with a defined lifetime — child tasks are cancelled when their parent is, and cancellation is cooperative, checked at await points or explicitly via Task.checkCancellation() — a real improvement over GCD's "nothing knows this work should stop." actor types give you compiler-enforced serialized access to their mutable state: the compiler rejects code that reaches into an actor's stored properties from outside without an await, closing off a whole category of data races that GCD only prevents by convention. @MainActor declares "this runs on the main thread" as a checked attribute instead of a runtime hop you have to remember to write.
#When GCD still earns its keep
Swift Concurrency didn't erase GCD's reasons to exist. Low-level primitives like DispatchSemaphore are still the right tool for gating access in code that must stay synchronous, or for bridging older synchronous APIs. A codebase with years of GCD-based infrastructure that isn't being rewritten wholesale is a legitimate reason to keep writing GCD in that area rather than mixing paradigms haphazardly. And any callback-based SDK you don't control — an older analytics library, a legacy authentication client — still hands you a completion closure, not an async function.
#Bridging the two
You don't have to choose one paradigm for the whole app at once. withCheckedContinuation (or withCheckedThrowingContinuation for a throwing variant) wraps exactly one callback-based call into an async function, so new call sites can be written in async/await while the legacy implementation underneath stays untouched. TaskGroup replaces DispatchGroup for fanning out parallel structured work with the same "wait for all to finish" shape, but with cancellation propagation built in.
#In code
// GCD: fetch two resources concurrently, then join.func loadDashboard(completion: @escaping (Profile, [Post]) -> Void) {let group = DispatchGroup()var profile: Profile!var posts: [Post] = []group.enter()api.fetchProfile { result inprofile = resultgroup.leave()}group.enter()api.fetchPosts { result inposts = resultgroup.leave()}group.notify(queue: .main) {completion(profile, posts)}}
// Swift Concurrency: structured, cancellable, reads top-to-bottom.func loadDashboard() async throws -> (Profile, [Post]) {async let profile = api.fetchProfile()async let posts = api.fetchPosts()return try await (profile, posts)}// Actor: compiler-enforced serialized access, no manual locking.actor ImageCache {private var storage: [URL: UIImage] = [:]func image(for url: URL) -> UIImage? {storage[url]}func insert(_ image: UIImage, for url: URL) {storage[url] = image}}// Bridging a legacy callback API into async/await, once.func fetchToken() async throws -> String {try await withCheckedThrowingContinuation { continuation inlegacyAuthClient.requestToken { token, error inif let token {continuation.resume(returning: token)} else {continuation.resume(throwing: error ?? AuthError.unknown)}}}}