Choosing Swift Concurrency for iOS/macOS: async/await vs GCD and Combine
Decide whether to adopt Swift async/await and actors or keep using GCD/Combine for iOS/macOS. Compare options, trade‑offs, and show a concrete network layer example with actor‑isolated cache and cancellation testing.
12 Sept 2025, 00:48 UTC

Decision Overview
When modernizing an iOS or macOS codebase, the most common question is whether to adopt Swift’s structured concurrency (async/await + actors) or continue using Grand Central Dispatch (GCD) with completion handlers or Combine. The decision hinges on three constraints:
- Deployment target – Full structured concurrency requires iOS 16+/macOS 13+, while the async/await syntax is available from iOS 13 with runtime shims.
- Existing codebase – A heavy GCD or Combine legacy layer may need bridging code.
- Team skill set – Developers comfortable with callbacks may need training on actors and task cancellation.
**Decision:** Adopt Swift async/await and actors for new code. Bridge legacy APIs with continuations. This gives linear, readable code, compile‑time safety, and built‑in cancellation while maintaining backward compatibility.
Option Comparison
| Aspect | async/await + Actors | GCD + Handlers | Combine |
|---|---|---|---|
| Readability | Linear, top‑down flow | Nested closures, callback hell | Declarative pipelines |
| Cancellation | Built‑in, automatic propagation | Manual flag checks | Built‑in via AnyCancellable |
| Back‑deploy | iOS 13+ (syntax), iOS 16+ (full features) | All iOS versions | iOS 13+ |
| Compile‑time safety | Actor isolation, @MainActor enforcement | None – data races possible | Type‑safe operators, but no actor model |
| Learning curve | Moderate – new syntax, actors | Low – GCD is familiar | High – reactive concepts |
Trade‑offs
Async/await eliminates callback nesting and exposes cancellation as a language feature. However, it raises the minimum OS for full structured concurrency; iOS 13‑15 only get the syntax, not task groups or distributed actors. GCD works everywhere but offers no compile‑time safety, so bugs surface at runtime. Combine adds powerful reactive tools but requires a steep learning curve and is less straightforward for simple request/response flows.
When a codebase contains a lot of shared mutable state, actors can replace locks, but they add a scheduling layer. If performance is critical, you must profile executor hopping and avoid unnecessary actor boundaries.
Concrete Implementation
Network Layer with async/await and Actor Isolation
Below is a minimal example that fetches JSON from a URL, caches it, and supports cancellation. The cache is an Actor to guarantee thread safety.
import Foundation
@available(iOS 13.0, *)
actor URLCacheActor {
private var storage: [URL: Data] = [:]
func get(for url: URL) -> Data? {
storage[url]
}
func set(_ data: Data, for url: URL) {
storage[url] = data
}
}
@available(iOS 13.0, *)
class NetworkService {
private let cache = URLCacheActor()
// Public API – async function that can be awaited
func fetchJSON(from url: URL) async throws -> Any {
// 1. Check cache
if let cached = await cache.get(for: url) {
return try JSONSerialization.jsonObject(with: cached)
}
// 2. Perform network request
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
// 3. Validate HTTP status
guard let httpRes = response as? HTTPURLResponse,
200 ..<= httpRes.statusCode < 300 else {
throw URLError(.badServerResponse)
}
// 4. Cache result
await cache.set(data, for: url)
return try JSONSerialization.jsonObject(with: data)
}
}
**Notes**
- On iOS 13‑14,
URLSession.shared.data(for:)is not available. Wrap the legacydataTask(with:completionHandler:)in a continuation:withCheckedContinuation { cont in ... }. - All operations that touch the cache use
await, ensuring actor isolation. - Cancellation is automatic: if the caller cancels the
Task, the network request is aborted and aCancellationErroris thrown.
Testing Cancellation
To validate that cancellation propagates correctly, create a Task and cancel it after a short delay:
let service = NetworkService()
let url = URL(string: "https://example.com/data.json")!
let task = Task {
try await service.fetchJSON(from: url)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
task.cancel()
}
// In a test case, you would assert that the task throws CancellationError.
Verifying Actor Isolation
Attempt to write to the cache from two concurrent tasks without await and observe the compiler error:
Task {
cache.storage[URL(string: "https://a")!] = Data() // ❌ illegal access
}
The compiler will flag this as a violation of actor isolation, preventing data races.
Back‑Deployment Strategy
When targeting iOS 13‑15, you must:
- Use
withCheckedContinuationto bridge completion‑handler APIs. - Avoid
TaskGrouporasync letconstructs that require iOS 16+. - Guard any iOS 16+ specific code with
@availablechecks or separate targets.
Example of a bridge:
func fetchDataLegacy(url: URL) async throws -> Data {
try await withCheckedThrowingContinuation { cont in
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
cont.resume(throwing: error)
} else if let data = data {
cont.resume(returning: data)
} else {
cont.resume(throwing: URLError(.badServerResponse))
}
}.resume()
}
}
Migration Roadmap
- Wrap legacy APIs – Create async wrappers using continuations for all completion‑handler functions.
- Convert call sites – Replace callbacks with
awaitwhere possible. - Replace shared state – Migrate global variables and singletons to
Actors or@MainActorfor UI. - Enable strict concurrency – Add
SWIFT_STRICT_CONCURRENCY=completeto build settings to surface remaining data races. - Adopt @MainActor for UI – Annotate view‑model methods that touch UIKit/AppKit.
- Profile and optimize – Use Instruments’ Swift Concurrency instrument to detect executor hopping or priority inversions.
Limitations and Practical Checks
- Actors are only compile‑time safe for same‑module code. External modules imported with
@preconcurrencycan bypass checks; review those imports. - URLSession async APIs throw on non‑2xx responses; legacy code may not have handled this. Add explicit status checks if needed.
- Background URLSession support is limited on iOS 13‑14; consider a separate legacy path if you need background downloads.
- When upgrading to Swift 6 (Xcode 16), strict concurrency is on by default. Plan migration before the upgrade to avoid widespread errors.
To confirm the migration succeeded, run a unit test that creates multiple Task instances calling the same async function, then assert that no data races occur and that cancellation propagates correctly. Use XCTestExpectation for async tests on older OS versions and async test methods on iOS 15+.
Conclusion
For new iOS/macOS code, Swift async/await with actors offers the most readable, safe, and cancelable concurrency model. With careful bridging of legacy APIs and a disciplined migration plan, you can modernize your codebase while supporting older deployment targets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.