SwiftQA Random

When do you still use GCD over async/await?

GCD hasn’t disappeared just because async/await exists — there are specific, real cases where it’s still the right tool.

1. Interop with APIs that are fundamentally callback/queue-based

A huge amount of Apple’s own frameworks and third-party SDKs still expose GCD-based or completion-handler APIs. Staying in GCD’s idiom at that boundary is often simpler than bridging every single call through withCheckedContinuation:

someQueue.async {
    legacyAPI.doThing { result in
        DispatchQueue.main.async {
            self.updateUI(result)
        }
    }
}

If you’re touching this code rarely and it works, wrapping it purely for consistency isn’t always worth the churn.

2. DispatchQueue’s specific synchronization primitives — barriers, DispatchGroup, DispatchSemaphore

  • DispatchGroup for waiting on a set of heterogeneous, non-structured units of work, especially mixing GCD-based and callback-based work.
  • Barrier blocks on a concurrent queue for a classic reader-writer synchronization pattern — an actor is often the better modern replacement, but if you’re already deep in a GCD-based subsystem, staying consistent can be less disruptive.
  • DispatchSemaphore for bridging genuinely synchronous code that must block a thread and wait — rare, usually a smell, occasionally needed at a low-level interop boundary.

3. Precise queue/thread control that actors don’t give you

  • QoS-specific dispatching where you need fine control over exact quality-of-service tiers.
  • Specific queue identity checks — dispatchPrecondition(condition: .onQueue(specificQueue)) requires an actual DispatchQueue.

4. Timers and simple delayed execution where Task.sleep + cancellation is overkill

DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
    self.dismissBanner()
}

For a trivial one-off UI delay, asyncAfter is arguably simpler and doesn’t need a Task wrapper, error handling for cancellation, or @MainActor isolation reasoning.

5. Codebases not yet migrated, where introducing async/await piecemeal creates more risk than value

In a large, GCD-heavy legacy codebase, converting one function to async/await often has ripple effects — callers need to become async too, or you need continuation-bridging shims at every boundary. If a specific piece of code isn’t causing an active problem, migrating it purely for modernity’s sake is often not worth the risk/effort.

6. Performance-sensitive, extremely fine-grained dispatching in hot paths

There are edge cases (audio processing, real-time-sensitive paths) where GCD’s queues offer more predictable, lower-level scheduling behavior than the Swift concurrency runtime’s cooperative thread pool — a narrow, specialized case worth mentioning to show awareness but not something to over-claim expertise on unless you’ve genuinely profiled and compared both.

What’s clearly NOT a good reason anymore

  • “async/await doesn’t handle X” — structured concurrency covers what GCD used to handle, generally more safely.
  • Familiarity alone — was legitimate for a couple years post-Swift 5.5; weaker justification for new code today.
  • Avoiding actor/Sendable migration pain — using GCD to sidestep Swift 6 concurrency checking is a real regression in safety, not a neutral stylistic choice.

The honest framing for an interview

“For new code, I’d default to async/await essentially always — it’s safer (Sendable-checked), composes better with structured concurrency. Where I’d still reach for GCD: wrapping legacy callback-based APIs where a full async bridge isn’t worth the churn, DispatchGroup when I’m grouping heterogeneous work that isn’t itself async, precise QoS/queue-identity requirements that actors don’t expose, and simple one-off delays where Task.sleep’s structured-concurrency overhead buys me nothing. What I wouldn’t do is reach for GCD specifically to avoid dealing with a Sendable error — that’s usually a sign the underlying code has a real data-race risk I should actually fix, not route around.”

One-liner if pressed: “I still reach for GCD at legacy interop boundaries, for constructs without a clean async equivalent like DispatchGroup or queue-identity preconditions, and for trivial one-off delays where structured concurrency’s overhead buys nothing — but not as a way to dodge a Sendable error, since that’s usually pointing at a real race I should fix rather than route around.”