- Published on
> Async/Await Basics in Swift
- Authors

- Name
- Mick MacCallum
- @0x7fs
Swift's async/await syntax, introduced in Swift 5.5, transformed how we write concurrent code. Instead of nested completion handlers, you write code that reads like synchronous logic while executing asynchronously. The compiler handles the complexity of suspending and resuming execution.
The Basics
An async function is declared with the async keyword. Inside it, you use await to call other async functions:
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
The await keyword marks a suspension point. When execution reaches it, the function might pause while waiting for the network response. During that pause, the thread is free to do other work. It's not blocked.
To call an async function, you need an async context. The most common way to create one is with Task:
Task {
do {
let user = try await fetchUser(id: "123")
print(user.name)
} catch {
print("Failed to fetch user: \(error)")
}
}
Async Properties and Subscripts
Properties can be async too, which is useful for lazy-loaded or computed values:
struct ImageLoader {
let url: URL
var image: UIImage {
get async throws {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
}
}
// Usage
let loader = ImageLoader(url: imageURL)
let image = try await loader.image
Async Sequences
When you have multiple values arriving over time, async sequences let you iterate with a familiar for loop:
func processNotifications() async {
for await notification in NotificationCenter.default.notifications(named: .userDidLogin) {
print("User logged in: \(notification)")
}
}
The loop suspends between iterations, waiting for the next value. It only exits when the sequence finishes or you break out of it.
You can also create your own async sequences using AsyncStream:
func countdown(from start: Int) -> AsyncStream<Int> {
AsyncStream { continuation in
for i in (0...start).reversed() {
continuation.yield(i)
try? await Task.sleep(for: .seconds(1))
}
continuation.finish()
}
}
// Usage
for await count in countdown(from: 5) {
print(count)
}
print("Liftoff!")
Parallel Execution with async let
By default, await calls execute sequentially. When you have independent operations, use async let to run them concurrently:
func fetchDashboard() async throws -> Dashboard {
async let user = fetchUser()
async let posts = fetchPosts()
async let notifications = fetchNotifications()
// All three requests run in parallel
// We wait for all of them here
return try await Dashboard(
user: user,
posts: posts,
notifications: notifications
)
}
Each async let starts its work immediately. The await expressions collect the results. If any of them throws, the error propagates and the other tasks are cancelled.
Task Groups for Dynamic Concurrency
When you don't know the number of concurrent operations at compile time, use task groups:
func fetchAllUsers(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
Tasks in the group run concurrently (up to system limits). Results come back in completion order, not submission order. If you need original order, track indices or use a dictionary.
Cancellation
Swift's concurrency model includes cooperative cancellation. You can check if a task is cancelled and respond appropriately:
func processItems(_ items: [Item]) async throws {
for item in items {
// Check for cancellation
try Task.checkCancellation()
// Or check without throwing
if Task.isCancelled {
break
}
await process(item)
}
}
Cancellation is cooperative. Nothing forces your code to stop. You check for it and decide what to do. Built-in async functions like URLSession.data(from:) already handle cancellation internally.
To cancel a task from outside:
let task = Task {
try await longRunningOperation()
}
// Later...
task.cancel()
MainActor for UI Updates
UI updates must happen on the main thread. The @MainActor attribute ensures code runs there:
@MainActor
class ViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await fetchAllUsers()
} catch {
// Handle error
}
}
}
Since ViewModel is marked @MainActor, all its methods run on the main thread. The fetchAllUsers() call still happens on a background thread (it's an async function), but assignment to users and isLoading happens on main.
You can also use MainActor.run for one-off main thread work:
func fetchAndDisplay() async throws {
let data = try await fetchData() // Background
await MainActor.run {
self.displayData(data) // Main thread
}
}
Bridging to Completion Handlers
When working with older APIs that use completion handlers, wrap them with continuations:
func fetchLegacyData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
LegacyAPI.fetch { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
The continuation must be resumed exactly once. Resuming zero times hangs forever; resuming multiple times crashes. Use withCheckedContinuation during development because it asserts on misuse. In production, withUnsafeContinuation skips the checks for performance.
SwiftUI Integration
SwiftUI has first-class support for async code. The .task modifier runs async work tied to a view's lifecycle:
struct UserView: View {
let userId: String
@State private var user: User?
var body: some View {
Group {
if let user {
Text(user.name)
} else {
ProgressView()
}
}
.task {
user = try? await fetchUser(id: userId)
}
}
}
The task starts when the view appears and cancels automatically when it disappears. If userId changes, you can use .task(id: userId) to restart the task.
Common Patterns
Debouncing search input:
@MainActor
class SearchViewModel: ObservableObject {
@Published var query = ""
@Published var results: [Result] = []
private var searchTask: Task<Void, Never>?
func search() {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = await performSearch(query)
}
}
}
Retry with backoff:
func fetchWithRetry<T>(
maxAttempts: Int = 3,
operation: () async throws -> T
) async throws -> T {
var lastError: Error?
for attempt in 0..<maxAttempts {
do {
return try await operation()
} catch {
lastError = error
let delay = Double(1 << attempt) // 1, 2, 4 seconds
try? await Task.sleep(for: .seconds(delay))
}
}
throw lastError!
}
Async/await makes concurrent code approachable. The syntax looks synchronous, but the execution is asynchronous and efficient. Combined with actors for thread safety, it's a significant improvement over the callback-based patterns of the past.
// Continue_Learning
Async/Await vs GCD in Swift: When to Use Each
Both async/await and Grand Central Dispatch solve concurrency problems, but they approach them differently. Here's how to choose between them.
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.
Resource Isolation in Swift Using Actors
Actors provide compile-time safety for shared mutable state in Swift. Here's when to use them and how they compare to older approaches.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.