Swift Concurrency

Run slow work without freezing your app using async and await, start work with Task, run jobs in parallel with async let and task groups, and protect shared state with actors and @MainActor.

Lesson 20 of 43Advanced17 min read

Some work takes time: downloading data, reading a large file, resizing a photo. If an app did that work on the main thread, which also draws the screen and handles taps, the interface would freeze until it finished. Users notice a frozen app within a fraction of a second.

Swift's modern concurrency model, introduced in Swift 5.5, makes slow work easy to write without blocking. You mark a function async to say it can pause while it waits, and call it with await. While one task is paused waiting for the network, Swift runs other work, including keeping the interface responsive. The code still reads from top to bottom, without the nested callbacks older Swift code needed.

Swift 6 goes further and checks for data races at compile time: two pieces of code changing the same data at the same time. In this lesson you'll write async functions, start tasks, run work in parallel with async let and task groups, cancel work, and protect shared data with actors and @MainActor.

async and await

Swift
func fetchUser(id: Int) 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)
}
  • async in the declaration means the function can suspend (pause) while it waits.
  • await marks each point where it might suspend. Other work runs in the meantime.
  • async throws functions are called with try await.
  • Only async code can call async functions with await, so the keyword spreads up through callers until something starts a task.

Starting work with Task

From ordinary synchronous code, such as a button's action, start async work with Task:

Swift
Button("Load") {
    Task {
        do {
            user = try await fetchUser(id: 42)
        } catch {
            message = "Couldn't load the profile."
        }
    }
}

In SwiftUI, the .task { } modifier starts async work when a view appears and cancels it automatically when the view disappears, which is usually the best place to load a screen's data.

Running work in parallel

Awaiting one call after another runs them in sequence. To run independent work at the same time, use async let:

Swift
async let profile = fetchProfile()
async let orders = fetchOrders()
let (p, o) = try await (profile, orders)    // both run at once

For a number of jobs only known at run time, such as downloading every image in a list, use a task group: withTaskGroup(of:) adds one child task per job and collects the results as they finish. Child tasks are cancelled automatically if the parent is cancelled, which is called structured concurrency.

Actors and the main actor

An actor is like a class that protects its own data: only one piece of code can use its state at a time, so concurrent access can't corrupt it. Code outside an actor must await its methods.

Swift
actor ImageCache {
    private var images: [URL: Data] = [:]
    func store(_ data: Data, for url: URL) { images[url] = data }
    func image(for url: URL) -> Data? { images[url] }
}

@MainActor marks code that must run on the main thread, which is required for anything that updates the UI. SwiftUI views and most view models are main-actor isolated, so their properties are updated safely.

Swift Concurrency Syntax

Concurrency syntax

func load() async throws -> Data { ... }
let data = try await load()
Task { await refresh() }
async let a = fetchA()
async let b = fetchB()
let results = await (a, b)
try await Task.sleep(for: .seconds(1))
let total = await withTaskGroup(of: Int.self) { group in
    for n in 1...3 { group.addTask { n * n } }
    return await group.reduce(0, +)
}
actor Counter { var value = 0; func increment() { value += 1 } }
@MainActor final class ViewModel { var items: [String] = [] }

Swift Concurrency Summary

  • async functions can suspend; call them with await (or try await).
  • Task { } starts async work from synchronous code; SwiftUI's .task ties it to a view's lifetime.
  • async let runs independent work in parallel; task groups handle a dynamic number of jobs.
  • Cancellation is cooperative: check Task.isCancelled or call Task.checkCancellation().
  • Actors protect shared state; @MainActor keeps UI work on the main thread.

Swift Concurrency Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Sequential versus parallel

Swift
func fetch(_ name: String, seconds: Double) async -> String {
    try? await Task.sleep(for: .seconds(seconds))
    return name
}

let clock = ContinuousClock()

let sequential = await clock.measure {
    _ = await fetch("profile", seconds: 1)
    _ = await fetch("orders", seconds: 1)
}

let parallel = await clock.measure {
    async let profile = fetch("profile", seconds: 1)
    async let orders = fetch("orders", seconds: 1)
    let both = await [profile, orders]
    print(both)
}

print("Sequential about \(Int(sequential.components.seconds))s")
print("Parallel about \(Int(parallel.components.seconds))s")
Output
["profile", "orders"]
Sequential about 2s
Parallel about 1s
Line-by-line explanation
  1. Line 2Task.sleep pauses this task without blocking a thread, standing in for a network call.
  2. Lines 8 to 11Each await waits for the previous call to finish, so the times add up.
  3. Lines 13 to 18async let starts both calls straight away; awaiting them waits for whichever finishes last.
  4. Lines 20 to 21The exact times vary slightly; the parallel version takes about half as long.
Example 2

A task group

Swift
import Foundation

func price(of item: String) async -> Double {
    let prices = ["apple": 0.5, "bread": 1.2, "milk": 0.9]
    return prices[item] ?? 0
}

let basket = ["apple", "bread", "milk", "apple"]

let total = await withTaskGroup(of: Double.self) { group in
    for item in basket {
        group.addTask { await price(of: item) }
    }
    var sum = 0.0
    for await value in group {
        sum += value
    }
    return sum
}

print("Items: \(basket.count)")
print("Total: £\(String(format: "%.2f", total))")
Output
Items: 4
Total: £3.10
Line-by-line explanation
  1. Line 1String(format:) comes from Foundation, so the file imports it.
  2. Line 10withTaskGroup creates a group whose child tasks each return a Double.
  3. Lines 11 to 13One child task is added per item, and they run concurrently.
  4. Lines 15 to 17for await collects results in the order they finish, which can vary.
  5. Line 18The group waits for every child before returning.
Example 3

An actor protecting shared state

Swift
actor VisitCounter {
    private var visits: [String: Int] = [:]

    func record(_ page: String) {
        visits[page, default: 0] += 1
    }

    func count(for page: String) -> Int {
        visits[page, default: 0]
    }
}

let counter = VisitCounter()

await withTaskGroup(of: Void.self) { group in
    for _ in 1...1000 {
        group.addTask { await counter.record("/home") }
    }
}

print(await counter.count(for: "/home"))
Output
1000
Line-by-line explanation
  1. Line 1An actor lets only one task use its state at a time.
  2. Lines 4 to 6Inside the actor, methods use visits directly without await.
  3. Lines 15 to 19A thousand tasks record visits concurrently.
  4. Line 17Outside the actor, calls need await because they may wait their turn.
  5. Line 21Every visit is counted; with a plain class and no protection, some updates could be lost.

Common Swift Concurrency Mistakes

Calling async code without await

Don't
let user = try fetchUser(id: 1)          // error: expression is 'async' but is not marked with 'await'
Do
let user = try await fetchUser(id: 1)

Awaiting independent calls one by one

Don't
let profile = try await fetchProfile()
let orders = try await fetchOrders()      // waits for the profile first, for no reason
Do
async let profile = fetchProfile()
async let orders = fetchOrders()
let (p, o) = try await (profile, orders)

Updating the UI from a background task

Don't
Task.detached {
    let items = await loadItems()
    self.items = items                    // UI state changed off the main actor
}
Do
@MainActor func reload() async {
    items = await loadItems()             // runs on the main actor
}

Swift Concurrency in Real Projects

Concurrency in iOS apps

Nearly every app loads data from a server, and modern apps do it with try await URLSession.shared.data(from:) inside a SwiftUI .task modifier. Screens that need several requests use async let to cut their loading time. Shared resources such as image caches and database connections are wrapped in actors, and Swift 6's strict checking catches data races before an app ships.

Swift Concurrency FAQ

What is the difference between async/await and completion handlers in Swift?

A completion handler passes a closure that's called later with the result, which leads to nested callbacks and easy-to-miss error paths. async/await lets you write the same work as straight-line code with normal try, return and loops, and the compiler checks that each result is handled.

Does await block the main thread?

No. await suspends the current task and frees the thread to do other work, such as keeping the UI responsive, until the result is ready. What does block is slow synchronous work run on the main actor, so move heavy computation off it.

What is the difference between Task and .task in SwiftUI?

Task { } starts async work from anywhere, such as a button action, and you're responsible for cancelling it if needed. The .task { } view modifier starts work when the view appears and cancels it automatically when the view disappears, which suits loading a screen's data.

What is @MainActor in Swift?

@MainActor marks a function, property or type that must run on the main thread. UI updates must happen there, so SwiftUI views and view models use it, and the compiler makes other code await when calling into main-actor code from elsewhere.

What is the difference between an actor and a class in Swift?

Both are reference types, but an actor lets only one task touch its mutable state at a time, which prevents data races. Code outside the actor must use await to call its methods or read its mutable properties. Actors also don't support inheritance.

Swift Concurrency Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the call to the async function.

let data = try  download(url)
Multiple choiceeasy

What does marking a function async mean?

Predict the outputmedium

Roughly how long does this take?

async let a = wait(seconds: 2)
async let b = wait(seconds: 2)
_ = await (a, b)
Multiple choicemedium

What does an actor guarantee?

Multiple choicehard

Why is SwiftUI's .task modifier often better than Task { } in onAppear?

Swift Concurrency Quiz

5 questions to check what you learned.

  1. How do you call an async throwing function?

    • await try
    • async try
    • try await
    • try async
  2. How do you start async work from a normal button action?

    • Call it directly
    • Wrap it in Task { }
    • Use DispatchQueue only
    • Mark the button async
  3. What does @MainActor ensure?

    • Code runs in parallel
    • Code runs on the main thread, which UI updates require
    • Code never throws
    • Code is cached
  4. What's the best tool for downloading a list of images whose length is only known at run time?

    • One async let per image
    • A for loop with await, one at a time
    • An actor
    • A task group
  5. How does cancellation work in Swift concurrency?

    • It's cooperative: code must check Task.isCancelled or call Task.checkCancellation()
    • Tasks stop instantly
    • It's impossible
    • It only works for actors