Swift Concurrency Explained: async/await, Tasks and Actors with Real Examples
A practical guide to modern Swift concurrency. Learn async/await, Task, TaskGroup, actors and @MainActor by building a small app feature step by step, plus the mistakes that cause bugs.
CodeOrbit Learn TeamPublished 7 min read
Almost every iOS app waits for something: a network call, a database read, an image to decode. If that waiting happens on the main thread, the screen freezes. For years we solved this with completion handlers and Grand Central Dispatch (GCD). Swift's modern concurrency model, introduced in Swift 5.5, gives us a cleaner way: async/await, tasks and actors.
In this guide we will build a small "student dashboard" feature that loads a profile, recent test scores and announcements at the same time, and use it to understand each tool.
Why completion handlers became painful
Here is a typical callback-based function:
func loadProfile(id: Int, completion: @escaping (Result<Profile, Error>) -> Void) {
URLSession.shared.dataTask(with: profileURL(id)) { data, _, error in
if let error { completion(.failure(error)); return }
guard let data else { completion(.failure(AppError.empty)); return }
do {
completion(.success(try JSONDecoder().decode(Profile.self, from: data)))
} catch {
completion(.failure(error))
}
}.resume()
}It works, but three problems appear as the app grows:
- Pyramid of callbacks: loading a profile, then scores, then announcements means nesting closures inside closures.
- Easy to forget a path: if one branch never calls
completion, the screen spins forever and the compiler doesn't warn you. - Thread confusion: the callback runs on a background queue, so updating the UI requires remembering
DispatchQueue.main.async.
async/await: write asynchronous code top to bottom
An async function can suspend while it waits, without blocking the thread. You call it with await.
struct Profile: Decodable { let name: String; let className: String }
func loadProfile(id: Int) async throws -> Profile {
let (data, response) = try await URLSession.shared.data(from: profileURL(id))
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw AppError.badStatus
}
return try JSONDecoder().decode(Profile.self, from: data)
}Read it like normal code: request, check status, decode. Errors flow through throws, so the compiler forces callers to handle them. There is no way to "forget" to return.
Calling async code from SwiftUI with Task
You cannot call an async function from a regular button action directly. You start a Task, a unit of asynchronous work:
struct ProfileView: View {
@State private var profile: Profile?
@State private var errorText: String?
var body: some View {
VStack {
if let profile { Text("Hello, \(profile.name)") }
if let errorText { Text(errorText).foregroundStyle(.red) }
}
.task { // starts when the view appears,
do { // cancels automatically when it disappears
profile = try await loadProfile(id: 42)
} catch {
errorText = "Could not load profile."
}
}
}
}The .task modifier is the best place to load data for a view: SwiftUI starts it when the view appears and cancels it when the view goes away, so you don't update a screen that is no longer visible.
Running work in parallel: async let
Our dashboard needs three independent things. Awaiting them one after another wastes time:
// Slow: total time = profile + scores + announcements
let profile = try await loadProfile(id: id)
let scores = try await loadScores(id: id)
let news = try await loadAnnouncements()With async let the three requests start together and we wait for all of them at the end:
func loadDashboard(id: Int) async throws -> Dashboard {
async let profile = loadProfile(id: id)
async let scores = loadScores(id: id)
async let news = loadAnnouncements()
// Total time ≈ the slowest of the three
return try await Dashboard(profile: profile, scores: scores, news: news)
}If any of them throws, the others are cancelled automatically. This "structured" behaviour is the big win: child work never outlives its parent.
Many tasks of unknown count: TaskGroup
async let is for a fixed number of jobs. When the number depends on data, for example downloading the thumbnail of every chapter, use a TaskGroup:
func loadThumbnails(for chapters: [Chapter]) async -> [Int: UIImage] {
await withTaskGroup(of: (Int, UIImage?).self) { group in
for chapter in chapters {
group.addTask {
(chapter.id, try? await downloadImage(chapter.thumbnailURL))
}
}
var result: [Int: UIImage] = [:]
for await (id, image) in group {
if let image { result[id] = image }
}
return result
}
}Results arrive in whatever order the downloads finish, which is why we return them keyed by chapter id.
Actors: safe shared state
Concurrency bugs usually come from data races: two pieces of code changing the same value at the same time. Imagine a cache of downloaded images used by many tasks:
final class ImageCache { // ⚠️ not safe
private var store: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? { store[url] }
func insert(_ image: UIImage, for url: URL) { store[url] = image }
}Two tasks inserting at once can corrupt the dictionary. An actor fixes this by allowing only one task inside it at a time:
actor ImageCache {
private var store: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? { store[url] }
func insert(_ image: UIImage, for url: URL) { store[url] = image }
}
let cache = ImageCache()
let cached = await cache.image(for: url) // calls from outside need awaitThe await is the cost of safety: if another task is using the actor, yours waits its turn.
@MainActor: the UI lives on the main thread
UIKit and SwiftUI must be updated on the main thread. Mark view models with @MainActor and Swift guarantees it for you:
@MainActor
@Observable
final class DashboardModel {
var dashboard: Dashboard?
var isLoading = false
var error: String?
func load(id: Int) async {
isLoading = true
defer { isLoading = false }
do {
dashboard = try await loadDashboard(id: id) // network runs off the main thread
} catch {
self.error = "Please check your internet and try again."
}
}
}Network work inside loadDashboard runs elsewhere; when it returns, the assignment to dashboard happens safely back on the main actor.
Cancellation: stop work nobody needs
Tasks are cancelled cooperatively. Long loops should check:
for chapter in chapters {
try Task.checkCancellation() // throws CancellationError if cancelled
try await process(chapter)
}URLSession's async methods already respond to cancellation, so most network code gets this for free.
GCD vs Swift concurrency at a glance
Need | Old way (GCD) | Modern way |
|---|---|---|
Run something later |
|
|
Wait for a result | completion handler |
|
Several jobs together |
|
|
Protect shared state | serial queue or locks |
|
Update UI |
|
|
Common mistakes
- Creating `Task {}` everywhere. Unstructured tasks aren't cancelled with the view. Prefer
.taskin SwiftUI andasync let/groups inside functions. - Blocking inside async code.
Thread.sleepor heavy synchronous loops still block the thread; usetry await Task.sleep(for: .seconds(1))and move heavy CPU work to a detached task if needed. - Ignoring errors with `try?` everywhere. You lose the reason a screen is empty. Catch and show a friendly message.
- Assuming order. Results from groups arrive in completion order, not submission order.
Practice task
Extend the dashboard: add a loadStreak(id:) call that runs in parallel with the other three, and show a red banner when any request fails. Then turn on Strict Concurrency Checking in your build settings and fix the warnings: it is the fastest way to learn where data races hide.
Tags:#Swift#iOS#Concurrency#SwiftUI
Frequently asked questions
Does async/await create a new thread for every call?
No. Swift uses a small pool of threads. An `await` suspends the function and frees the thread for other work; the function later resumes on whichever thread is available.
When should I still use GCD?
Mostly when working with older APIs that require dispatch queues. For new code, async/await, tasks and actors are clearer and the compiler checks them for data races.
What is the difference between an actor and a class?
Both are reference types, but an actor lets only one task access its mutable state at a time. Calls from outside an actor must use `await`.
Why does my UI update produce a purple warning?
You are changing UI state from a background thread. Mark the view model (or the method) with `@MainActor`.
Related posts
iOS
SwiftUI Data Flow: @State, @Binding, @Observable and @Environment Explained
Learn which SwiftUI property wrapper to use and why, by building a small study-timer app. Clear rules, diagrams in words, and the mistakes that make views not update.
4 min read