Skip to content
Sign in

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:

Swift
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.

Swift
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:

Swift
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:

Swift
// 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:

Swift
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:

Swift
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:

Swift
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:

Swift
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 await

The 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:

Swift
@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:

Swift
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

DispatchQueue.global().async

Task { }

Wait for a result

completion handler

await

Several jobs together

DispatchGroup

async let / TaskGroup

Protect shared state

serial queue or locks

actor

Update UI

DispatchQueue.main.async

@MainActor

Common mistakes

  1. Creating `Task {}` everywhere. Unstructured tasks aren't cancelled with the view. Prefer .task in SwiftUI and async let/groups inside functions.
  2. Blocking inside async code. Thread.sleep or heavy synchronous loops still block the thread; use try await Task.sleep(for: .seconds(1)) and move heavy CPU work to a detached task if needed.
  3. Ignoring errors with `try?` everywhere. You lose the reason a screen is empty. Catch and show a friendly message.
  4. 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`.