The Evolution of Asynchronous Programming in Swift

Before Swift 5.5, asynchronous programming relied heavily on completion handlers, delegates, and third-party libraries like Combine or ReactiveSwift. While these approaches worked, they often led to deeply nested closures—commonly called "callback hell"—and made error handling and control flow difficult to follow. Swift Concurrency, introduced in Swift 5.5, fundamentally changes this landscape by offering a native, structured approach to writing asynchronous code. It leverages language-level keywords async and await, introduces structured concurrency with Task groups, and provides actors for safe mutable state. This article explores each building block in depth, with practical examples and best practices for modern iOS and macOS development.

Why adopt Swift Concurrency? Beyond cleaner syntax, it integrates with the runtime to handle cooperative cancellation, thread safety, and performance optimizations automatically. By the end of this guide, you'll have a thorough understanding of how to use Swift Concurrency to build responsive, maintainable apps without fighting with threads or reentrancy issues.

Async/Await: The Foundation

The async keyword marks a function, method, or closure as asynchronous, while await suspends execution until the async operation completes. This syntactic sugar makes asynchronous code look nearly identical to synchronous code, dramatically improving readability.

Defining Async Functions

To declare an async function, add the async keyword before the function's return arrow. You can also combine it with throws for error propagation:

func fetchUserProfile(id: UUID) async throws -> Profile {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw FetchError.invalidResponse
    }
    return try JSONDecoder().decode(Profile.self, from: data)
}

Notice the use of try await – the await keyword indicates a suspension point, and if the call throws, the function propagates the error. Unlike completion handlers, you don't need separate success/failure callbacks. The control flow is linear and easy to trace.

Calling Async Functions

You can only call an async function from within an async context. If you're writing an async function, simply use await before the call. If you're in synchronous code (e.g., a view controller lifecycle method), you need to wrap the call in a Task:

func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    Task {
        do {
            let profile = try await fetchUserProfile(id: currentUserID)
            updateUI(with: profile)
        } catch {
            showError(error)
        }
    }
}

Here, Task creates a new asynchronous context on behalf of the caller. The child task inherits the parent's priority and actor isolation, but it does not replace the main thread. Swift Concurrency uses a cooperative thread pool, so the system decides where the code runs without blocking the UI.

Async Let Bindings

When you need to perform multiple independent asynchronous operations concurrently, use async let. This syntax starts each operation immediately and awaits the results later:

func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()
    async let recentPosts = fetchRecentPosts()
    async let notifications = fetchNotifications()
    
    // All three tasks run concurrently; await once for each
    return try await Dashboard(user: user, posts: recentPosts, notifications: notifications)
}

The async let bindings create child tasks that execute in parallel. When you await each binding, you collect the result or rethrow an error from that specific child. This is more efficient than sequential await calls because the total time is the maximum of the individual tasks, not their sum.

Structured Concurrency with Task Groups

While async let works well for a fixed number of tasks, Task groups allow you to add an arbitrary number of child tasks dynamically. The withTaskGroup(of:returning:body:) and withThrowingTaskGroup functions create a scope where you can spawn tasks and collect their results.

Dynamic Concurrency

Imagine fetching images for a gallery from an array of URLs. Each image download is independent, so we can use a task group to fetch them concurrently:

func fetchImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else {
                    throw ImageError.invalidData
                }
                return image
            }
        }
        
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Key points about task groups:

  • Child tasks inherit the parent's cancellation state and priority.
  • The group's addTask method returns immediately; execution starts on a global concurrent executor.
  • The for try await image in group loop collects results in the order they complete, so you can update a UI progress bar incrementally.
  • If any child task throws, the group automatically cancels all remaining tasks, and the error propagates (if you use withThrowingTaskGroup).

Cancellation Cooperation

Swift's runtime supports cooperative cancellation. Inside a task, check Task.isCancelled periodically, or call try Task.checkCancellation() to throw a CancellationError. Task groups automatically propagate cancellation to child tasks when the parent task is cancelled (e.g., because a user navigates away).

func processData(with placeholder: String) async throws {
    // Check before starting a long operation
    try Task.checkCancellation()
    
    // Simulate work that can be cancelled
    for step in 0..<100 {
        try Task.checkCancellation()
        // Do some work...
        await Task.yield() // Yield to the scheduler
    }
}

Actors: Safe Shared State Without Locks

Swift Concurrency introduces actors to protect mutable state. An actor synchronizes access to its properties and methods, ensuring that only one task executes on the actor at a time. This eliminates data races without manual locking.

Declaring and Using an Actor

actor Counter {
    private var value: Int = 0
    
    func increment() {
        value += 1
    }
    
    func getValue() -> Int {
        return value
    }
}

let counter = Counter()
Task {
    await counter.increment()
    let current = await counter.getValue()
    print("Counter: \(current)")
}

Every interaction with an actor's isolated state must use await because execution may need to wait if another task is currently on the actor. The compiler enforces this isolation, so you cannot accidentally access a mutable actor property from outside without await.

Non-Isolated Functions and Sendable

Actor methods that don't access mutable state can be marked nonisolated to avoid the await requirement. Values passed across actor boundaries must conform to Sendable – a protocol indicating the type is safe to share across concurrency domains. Swift automatically infers Sendable for value types and classes with only sendable properties. You can add manual conformance with @unchecked Sendable if you know it's safe.

MainActor and UI Updates

UIKit and SwiftUI are designed to be updated only from the main thread. Swift Concurrency provides the @MainActor global actor to run code on the main actor (the main thread). You can mark an entire class, a method, or a property with @MainActor to ensure UI updates are safe.

Example: View Controller with MainActor

@MainActor
class ProfileViewController: UIViewController {
    private var profile: Profile?
    private let imageView = UIImageView()
    
    func updateProfile(_ new: Profile) {
        self.profile = new
        imageView.image = new.avatar
    }
}

Now, any call to updateProfile must happen on the main actor. If you call it from a background task, use await:

Task {
    let profile = try await fetchUserProfile(id: userId)
    await profileController.updateProfile(profile)
}

You can also use MainActor.run to execute a closure on the main actor, but the compiler often handles this automatically if you mark the UI class appropriately.

Async Sequences and Streams

Swift Concurrency extends the idea of AsyncSequence – a protocol for types that produce values over time asynchronously. Common examples include URLSession.bytes(from:) which returns an AsyncBytes sequence, and NotificationCenter.notifications(named:) which produces notifications. You iterate using for await value in sequence.

Creating an AsyncStream

If you need to convert a callback-based API into an async sequence, use AsyncStream:

func locationStream() -> AsyncStream<CLLocation> {
    AsyncStream<CLLocation> { continuation in
        let manager = CLLocationManager()
        let delegate = LocationDelegate(continuation: continuation)
        manager.delegate = delegate
        manager.startUpdatingLocation()
        
        // Store delegate somewhere, or continuation.onTermination to clean up
    }
}

// Usage:
for await location in locationStream() {
    updateMap(with: location)
}

AsyncStream provides back-pressure support and can be cancelled by the consumer. Always call continuation.finish() when the stream ends.

Best Practices for Production Code

Adopting Swift Concurrency requires careful thought about architecture and performance. Here are key guidelines:

Prefer Structured Concurrency

Use async let and task groups instead of manually creating detached Task objects. Structured concurrency ensures child tasks are automatically cancelled when their parent is cancelled, preventing resource leaks and orphan tasks. Detached tasks (Task.detached) should be reserved for fire-and-forget operations where you explicitly do not want inheritance.

Avoid Unnecessary Task Creation

Only use Task { ... } when you need to start an async context from synchronous code. Inside an async function, simply await calls. Creating too many tasks can lead to thread explosion; the cooperative scheduler handles thousands of tasks efficiently, but each task has overhead.

Handle Errors Gracefully

Async functions that throw should be called with try await inside a do-catch block. Propagate errors up to a point where they can be presented to the user. In task groups, the for try await loop stops on first error, but you can also use group.addTaskUnlessCancelled for finer control.

Leverage Actors for Mutable State

Instead of using locks, semaphores, or serial queues, encapsulate mutable shared state inside an actor. This not only prevents data races but also improves readability because the compiler enforces isolation. For value types that are never mutated across concurrency boundaries, Sendable conformance is automatic.

Use @MainActor for UI Code

Always mark view controllers, views, and any class that updates the UI with @MainActor. This eliminates the need to manually dispatch to the main queue and avoids common pitfalls where UI updates happen off the main thread.

Test Async Code with Swift Testing

XCTest supports async tests since Xcode 14. Write tests with async throws to call your async functions directly:

func testFetchUserProfile() async throws {
    let profile = try await fetchUserProfile(id: testID)
    XCTAssertEqual(profile.name, expectedName)
}

Conclusion

Swift Concurrency fundamentally changes how we write asynchronous code on Apple platforms. By embracing async/await, actors, and structured concurrency, developers can produce code that is easier to read, safer from data races, and more responsive. The compiler and runtime handle the heavy lifting of scheduling, cancellation, and thread safety, allowing you to focus on the business logic.

For further reading, refer to Apple's official Swift Concurrency documentation, the WWDC 2021 session "Meet Async/Await in Swift", and practical tutorials from Ray Wenderlich's Modern Concurrency in Swift book. Experiment with these patterns in your own projects to see the immediate benefits in code clarity and performance.