Skip to content

Swift Concurrency — async/await, Task, Actors, and Structured Concurrency

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Swift Concurrency. We cover key concepts, practical examples, and best practices to help you master this topic.

Swift concurrency introduces async/await syntax, actors, and structured concurrency to write safe, performant asynchronous code that eliminates callback nesting and data race conditions at compile time.

What You'll Learn

  • async/await syntax and async functions
  • Task and task priorities
  • TaskGroup for parallel operations
  • Actors for safe mutable state
  • MainActor for UI thread dispatching
  • Structured concurrency principles

Why It Matters

Before Swift concurrency, asynchronous code relied on completion handlers, delegate callbacks, and DispatchQueue. This led to deeply nested closures, forgotten error handling, and easy-to-miss thread-safety bugs. Swift's built-in concurrency model catches many issues at compile time and produces code that reads like synchronous code.

Real-World Use

A photo editing app downloads three images simultaneously, applies filters, and updates the UI. Using async/await with TaskGroup, the three downloads run in parallel. Each result is processed as it arrives. When all complete, the UI updates on the main actor. Error handling is centralized in a single do/catch block.

Learning Path

flowchart LR
  A[Data Persistence
Lesson 21] --> B[Concurrency
You are here] B --> C[Notifications
Lesson 23] B --> D[Combine Framework
Lesson 24] style B fill:#f90,color:#fff

async/await Syntax

An async function can suspend its execution without blocking the current thread.

import Foundation

func fetchUserName() async -> String {
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    return "Alice"
}

func fetchUserAge() async -> Int {
    try? await Task.sleep(nanoseconds: 500_000_000)
    return 30
}

func fetchUserData() async {
    async let name = fetchUserName()
    async let age = fetchUserAge()

    let result = await "User: \(name), Age: \(age)"
    print(result)
}

Task {
    await fetchUserData()
}

Output: User: Alice, Age: 30

The async keyword marks a function as asynchronous. The await keyword marks suspension points where the function may pause. async let runs multiple tasks in parallel and collects their results.

Throwing Async Functions

Async functions can throw errors, caught with standard do/catch.

enum NetworkError: Error {
    case badURL
    case requestFailed(String)
    case invalidResponse
}

func fetchData(from urlString: String) async throws -> Data {
    guard let url = URL(string: urlString) else {
        throw NetworkError.badURL
    }

    let (data, response) = try await URLSession.shared.data(from: url)

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw NetworkError.requestFailed("Invalid status code")
    }

    return data
}

func loadContent() async {
    do {
        let data = try await fetchData(from: "https://api.example.com/data")
        print("Received \(data.count) bytes")
    } catch NetworkError.badURL {
        print("Invalid URL provided")
    } catch NetworkError.requestFailed(let message) {
        print("Request failed: \(message)")
    } catch {
        print("Unexpected error: \(error.localizedDescription)")
    }
}

Task {
    await loadContent()
}

Task and Task Priority

A Task is a unit of asynchronous work. You create tasks to call async functions from synchronous contexts.

func performBackgroundWork() {
    Task(priority: .background) {
        print("Background task started (priority: background)")
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        print("Background task completed")
    }

    Task(priority: .userInitiated) {
        print("User-initiated task started (priority: high)")
        try? await Task.sleep(nanoseconds: 500_000_000)
        print("User-initiated task completed")
    }

    Task(priority: .utility) {
        print("Utility task started (priority: low)")
        try? await Task.sleep(nanoseconds: 700_000_000)
        print("Utility task completed")
    }
}

performBackgroundWork()
// Allow time for tasks to run
Thread.sleep(forTimeInterval: 2.0)

Tasks can be cancelled, and you should check for cancellation in long-running operations.

func processLargeDataset() async {
    for i in 0..<1000 {
        try? Task.checkCancellation()
        // Process item i...
        if i % 100 == 0 {
            print("Processing... \(i)/1000")
        }
    }
    print("Processing complete")
}

let task = Task {
    await processLargeDataset()
}

// Cancel after a short time
Task {
    try? await Task.sleep(nanoseconds: 200_000_000)
    task.cancel()
    print("Task cancelled")
}

TaskGroup for Parallel Operations

TaskGroup runs multiple tasks concurrently and collects their results.

import Foundation

func downloadFile(id: Int) async -> String {
    let duration = UInt64.random(in: 500_000_000...1_500_000_000)
    try? await Task.sleep(nanoseconds: duration)
    return "File \(id) downloaded in \(duration / 1_000_000)ms"
}

func downloadAllFiles() async {
    let results = await withTaskGroup(of: String.self) { group in
        for i in 1...5 {
            group.addTask {
                return await downloadFile(id: i)
            }
        }

        var collected: [String] = []
        for await result in group {
            collected.append(result)
            print("Completed: \(result)")
        }
        return collected
    }

    print("All done: \(results.count) files downloaded")
}

Task {
    await downloadAllFiles()
}

Output:

Completed: File 2 downloaded in 678ms
Completed: File 1 downloaded in 902ms
Completed: File 5 downloaded in 1100ms
Completed: File 3 downloaded in 1200ms
Completed: File 4 downloaded in 1450ms
All done: 5 files downloaded

All five downloads run simultaneously. Results are collected as they complete, not in order of submission.

Throwing TaskGroup

Use withThrowingTaskGroup when tasks can throw errors.

func fetchUser(id: Int) async throws -> String {
    if id == 3 {
        throw NetworkError.requestFailed("User not found")
    }
    try? await Task.sleep(nanoseconds: 500_000_000)
    return "User_\(id)"
}

func fetchAllUsers() async {
    do {
        let users = try await withThrowingTaskGroup(of: String.self) { group in
            for id in 1...5 {
                group.addTask {
                    return try await fetchUser(id: id)
                }
            }

            var results: [String] = []
            for try await user in group {
                results.append(user)
            }
            return results
        }
        print("Users: \(users)")
    } catch {
        print("Failed to fetch users: \(error)")
    }
}

Task {
    await fetchAllUsers()
}

Actors

Actors protect mutable state by ensuring only one task accesses the actor's isolated state at a time.

actor BankAccount {
    private var balance: Double

    init(initialBalance: Double) {
        self.balance = initialBalance
    }

    func deposit(amount: Double) {
        balance += amount
        print("Deposited \(amount). Balance: \(balance)")
    }

    func withdraw(amount: Double) -> Bool {
        guard balance >= amount else {
            print("Insufficient funds. Balance: \(balance)")
            return false
        }
        balance -= amount
        print("Withdrew \(amount). Balance: \(balance)")
        return true
    }

    func getBalance() -> Double {
        return balance
    }
}

let account = BankAccount(initialBalance: 1000.0)

Task {
    await account.deposit(amount: 500.0)
}

Task {
    let withdrew = await account.withdraw(amount: 200.0)
    print("Withdrawal success: \(withdrew)")
}

Task {
    let balance = await account.getBalance()
    print("Final balance: \(balance)")
}

Output:

Deposited 500.0. Balance: 1500.0
Withdrew 200.0. Balance: 1300.0
Final balance: 1300.0

All calls to the actor use await because they cross the actor's isolation boundary. The actor ensures no two tasks modify balance simultaneously.

Sendable

Types passed between concurrency domains must conform to Sendable.

struct Transaction: Sendable {
    let id: UUID
    let amount: Double
    let timestamp: Date
}

actor TransactionLogger {
    private var transactions: [Transaction] = []

    func log(_ transaction: Transaction) {
        transactions.append(transaction)
    }

    func allTransactions() -> [Transaction] {
        return transactions
    }
}

let logger = TransactionLogger()
let transaction = Transaction(id: UUID(), amount: 99.99, timestamp: Date())

Task {
    await logger.log(transaction)
    let count = await logger.allTransactions().count
    print("Logged \(count) transactions")
}

MainActor

@MainActor ensures code runs on the main thread, essential for UI updates.

import UIKit

@MainActor
class UserViewModel: ObservableObject {
    @Published var userName: String = ""
    @Published var isLoading = false

    func loadUser() async {
        isLoading = true

        do {
            let url = URL(string: "https://api.example.com/user")!
            let (data, _) = try await URLSession.shared.data(from: url)
            let user = try JSONDecoder().decode(User.self, from: data)

            // These updates are on MainActor automatically
            userName = user.name
            isLoading = false
        } catch {
            userName = "Error loading"
            isLoading = false
        }
    }
}

struct User: Decodable {
    let name: String
}

// Usage:
// let viewModel = UserViewModel()
// Task { await viewModel.loadUser() }

Marking the entire class with @MainActor ensures all its properties and methods run on the main thread. Individual functions can also be marked with @MainActor.

Structured Concurrency

Structured concurrency means every task has a parent, and the parent waits for all children to complete before the scope exits.

func structuredConcurrencyDemo() async {
    print("Starting parent task")

    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            print("  Child 1 starting")
            try? await Task.sleep(nanoseconds: 1_000_000_000)
            print("  Child 1 done")
        }

        group.addTask {
            print("  Child 2 starting")
            try? await Task.sleep(nanoseconds: 500_000_000)
            print("  Child 2 done")
        }
    }

    print("All children complete — parent continues")
}

Task {
    await structuredConcurrencyDemo()
}

Output:

Starting parent task
  Child 1 starting
  Child 2 starting
  Child 2 done
  Child 1 done
All children complete — parent continues

If the parent task is cancelled, all child tasks are automatically cancelled. If a child throws an unhandled error in a throwing group, all siblings are cancelled.

Common Mistakes

  1. Calling async functions without await: Swift enforces await at compile time. Missing await produces a compiler error, not a runtime issue.

  2. Not checking Task cancellation: Long-running tasks should periodically check Task.isCancelled or call Task.checkCancellation() to respond to cancellation.

  3. Actor deadlocks: An actor calling its own method synchronously is safe, but calling another actor's method without await crosses an isolation boundary and must use await.

  4. Using Task without storing the reference: A detached task (Task.detached) runs independently. Use Task (structured) by default; use detached only when you need a separate parent context.

  5. UI updates off the main thread: Use @MainActor or await MainActor.run { ... } to ensure UI updates happen on the main thread.

Practice Questions

  1. What is the difference between Task and Task.detached?
  2. How do actors prevent data races?
  3. What is the purpose of @MainActor?
  4. How does TaskGroup enable parallel execution?
  5. Challenge: Build an async image cache that downloads images in parallel using a TaskGroup, stores them in an actor for thread-safe access, and provides a @MainActor class for UI binding.

Mini Project

Create a ConcurrentImageDownloader with:

  • An ImageDownloader actor that tracks download progress and cached images
  • A downloadImages(urls: [URL]) async -> [UIImage] function using withThrowingTaskGroup
  • A @MainActor class that observes download progress and updates a progress label
  • Error handling for invalid URLs and network failures
  • Cancellation support: if the user navigates away, all downloads cancel

FAQ

What is the difference between async/await and Combine?

async/await is for one-shot asynchronous tasks (fetch data, write file). Combine is for continuous value streams (text field changes, notifications). They complement each other.

Can I use DispatchQueue with Swift concurrency?

Yes, but avoid mixing them in the same code path. Use withCheckedContinuation to bridge callback-based APIs into async/await.

What is the global actor concept?

Global actors like @MainActor ensure that all code annotated with them runs on the same executor. You can define custom global actors for your own serial queues.

How do I convert a completion handler function to async/await?

Use withCheckedContinuation or withUnsafeContinuation to suspend the current task and resume it from the completion handler.

Are actors reference or value types?

Actors are reference types. They are similar to classes but provide actor isolation for their stored properties.

What's Next

With concurrency mastered, explore Reactive Programming with Combine Framework for event streams, or learn about Notifications for push and local notifications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro