Introduction
Asynchronous code has long been a source of headaches for iOS developers: nested completion handlers, callback hell, and hard-to-read chains make managing network requests, file I/O, and heavy computations challenging. With Swift 5.5 and iOS 15, Apple introduced Swift Concurrency, built around the async
/await
syntax, structured concurrency, and actors. This new model brings clarity, safety, and expressiveness to asynchronous programming, letting you write code that looks and feels synchronous while running efficiently in the background. In this post, we’ll dive into the core concepts of Swift Concurrency, walk through practical examples of async
/await
, Tasks, TaskGroups, error handling, and actors, and share best practices for adopting these features in your existing iOS apps.

Why Concurrency Matters in iOS
- Responsive UIs: Keeping your main thread free of blocking work ensures smooth scrolling and instant touch feedback.
- Efficient Resource Use: Background tasks like network fetches and image processing can take advantage of multicore CPUs.
- Readability & Maintainability: Well-structured asynchronous code is easier to reason about, debug, and extend.
Traditional GCD and callback-based patterns work, but they can lead to tangled code and subtle bugs. Swift Concurrency offers a clearer, safer alternative.
The Basics of async
/await
Declaring an Asynchronous Function
Use the async
keyword to mark functions that perform asynchronous work:
swiftCopyEditfunc fetchUserProfile() async throws -> UserProfile {
let (data, _) = try await URLSession.shared.data(from: profileURL)
return try JSONDecoder().decode(UserProfile.self, from: data)
}
async
indicates the function can suspend.throws
works alongsideasync
, letting you propagate errors.await
pauses execution until the asynchronous call completes.
Calling an async
Function
You can only call await
from within an asynchronous context:
swiftCopyEditTask {
do {
let profile = try await fetchUserProfile()
updateUI(with: profile)
} catch {
showError(error)
}
}
Task { … }
creates a new—detached—concurrent context.- Use
await
to get results, and standarddo
/catch
for error handling.
Structured Concurrency: Tasks and TaskGroups
Creating Child Tasks
Structured concurrency ensures that child tasks are tied to their parent, making cancellation and error propagation predictable:
swiftCopyEditfunc loadDashboardData() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await fetchRecentPosts() }
group.addTask { await fetchRecommendedUsers() }
group.addTask { await fetchNotifications() }
}
// All child tasks complete before reaching here
displayDashboard()
}
withTaskGroup
launches multiple child tasks in parallel.- The parent suspends until all children finish or an error is thrown.
Returning Values from a TaskGroup
You can also collect results in a group:
swiftCopyEditfunc fetchPostImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
var images: [UIImage] = []
for url in urls {
group.addTask {
let data = try await fetchData(from: url)
return UIImage(data: data)!
}
}
for try await image in group {
images.append(image)
}
return images
}
}
withThrowingTaskGroup
propagates the first error, cancelling remaining tasks.- Iterating over the group yields each child’s return value.
Cancellation and Timeouts

Checking for Cancellation
Swift concurrency lets you cooperative-cancel long-running tasks:
swiftCopyEditfunc performHeavyComputation() async throws -> Result {
for chunk in dataChunks {
try Task.checkCancellation()
process(chunk)
}
return finalResult
}
Task.checkCancellation()
throwsCancellationError
if the task was cancelled.
Adding Timeouts
Wrap work in withTimeout
or manually:
swiftCopyEditfunc fetchWithTimeout() async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await fetchData() }
group.addTask {
try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
throw URLError(.timedOut)
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
This pattern races your fetch against a timeout task, cancelling whichever loses the race
Actors: Safe Shared State
Defining an Actor
Actors protect mutable state by serializing access:
swiftCopyEditactor SessionManager {
private var token: String?
func updateToken(_ newToken: String) {
token = newToken
}
func getToken() -> String? {
token
}
}
actor
functions like a class but enqueues accesses on a private serial executor.- No data races; you can
await
actor methods from any context.
Using an Actor in Your App
swiftCopyEditlet session = SessionManager()
Task {
await session.updateToken("abc123")
}
Task {
if let token = await session.getToken() {
print("Current token: \(token)")
}
}
Actors ensure consistency without locks or manual synchronization.
Integrating with Existing Code and Combine
If you have legacy Combine pipelines, you can bridge them into Swift Concurrency:

swiftCopyEditextension URLSession {
func dataAsync(for url: URL) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { continuation in
let task = dataTask(with: url) { data, response, error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: (data!, response!))
}
}
task.resume()
}
}
}
- Continuations (
withCheckedThrowingContinuation
) convert callback-based APIs intoasync
functions. - This helps you gradually migrate to Swift Concurrency without rewriting everything at once.
Best Practices for Swift Concurrency
- Favor Structured Concurrency: Use
TaskGroup
over spawning detachedTask
s when tasks are related. - Check for Cancellation: Cancellable APIs should periodically call
Task.checkCancellation()
. - Avoid Blocking Inside Async: Don’t use
DispatchQueue.sync
orThread.sleep
—useTask.sleep
. - Minimize Actor Isolation: Keep actor-isolated methods coarse-grained to avoid excessive suspension points.
- Graceful Error Handling: Use
withThrowingTaskGroup
for concurrent tasks that may fail, and handle errors at the group boundary.
Migrating Legacy Code: A Step-by-Step Plan
- Identify Hotspots: Look for complex callback chains and Combine publishers driving UI updates.
- Wrap Callbacks with Continuations: Convert to
async
using checked continuations. - Refactor Networking Layers: Replace completion-handler-based network calls with
async
/await
. - Introduce Tasks Near Entry Points: Wrap view-controller event handlers (e.g., button taps) in
Task { … }
. - Adopt Structured Concurrency: Group related tasks to manage lifecycle and cancellations.
- Swap Synchronized State for Actors: Replace manually locked shared state with actors.
Conclusion
Swift Concurrency’s async
/await
, structured concurrency, and actors represent a seismic shift in how we write asynchronous code for iOS. By adopting these features, you’ll achieve more readable, maintainable, and robust apps—free from callback hell and unpredictable threading bugs. Start small by converting a single network call, then gradually embrace TaskGroups and actors to manage complex workflows and shared state. Before long, you’ll wonder how you ever lived without this powerful, modern concurrency model.