Swift Concurrency: Async/Await in iOS Apps

Table of Contents
Big thanks to our contributors those make our blogs possible.

Our growing community of contributors bring their unique insights from around the world to power our blog. 

Introduction

Asynchronous code has long been a source of headaches for iOS developers: nested completion handlers, callback hell, and hard-to-read chains make managing network requests, file I/O, and heavy computations challenging. With Swift 5.5 and iOS 15, Apple introduced Swift Concurrency, built around the async/await syntax, structured concurrency, and actors. This new model brings clarity, safety, and expressiveness to asynchronous programming, letting you write code that looks and feels synchronous while running efficiently in the background. In this post, we’ll dive into the core concepts of Swift Concurrency, walk through practical examples of async/await, Tasks, TaskGroups, error handling, and actors, and share best practices for adopting these features in your existing iOS apps.

Why Concurrency Matters in iOS

  • Responsive UIs: Keeping your main thread free of blocking work ensures smooth scrolling and instant touch feedback.
  • Efficient Resource Use: Background tasks like network fetches and image processing can take advantage of multicore CPUs.
  • Readability & Maintainability: Well-structured asynchronous code is easier to reason about, debug, and extend.

Traditional GCD and callback-based patterns work, but they can lead to tangled code and subtle bugs. Swift Concurrency offers a clearer, safer alternative.

The Basics of async/await

Declaring an Asynchronous Function

Use the async keyword to mark functions that perform asynchronous work:

swiftCopyEditfunc fetchUserProfile() async throws -> UserProfile {
    let (data, _) = try await URLSession.shared.data(from: profileURL)
    return try JSONDecoder().decode(UserProfile.self, from: data)
}
  • async indicates the function can suspend.
  • throws works alongside async, letting you propagate errors.
  • await pauses execution until the asynchronous call completes.

Calling an async Function

You can only call await from within an asynchronous context:

swiftCopyEditTask {
    do {
        let profile = try await fetchUserProfile()
        updateUI(with: profile)
    } catch {
        showError(error)
    }
}
  • Task { … } creates a new—detached—concurrent context.
  • Use await to get results, and standard do/catch for error handling.

Structured Concurrency: Tasks and TaskGroups

Creating Child Tasks

Structured concurrency ensures that child tasks are tied to their parent, making cancellation and error propagation predictable:

swiftCopyEditfunc loadDashboardData() async {
    await withTaskGroup(of: Void.self) { group in
        group.addTask { await fetchRecentPosts() }
        group.addTask { await fetchRecommendedUsers() }
        group.addTask { await fetchNotifications() }
    }
    // All child tasks complete before reaching here
    displayDashboard()
}
  • withTaskGroup launches multiple child tasks in parallel.
  • The parent suspends until all children finish or an error is thrown.

Returning Values from a TaskGroup

You can also collect results in a group:

swiftCopyEditfunc fetchPostImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        var images: [UIImage] = []
        for url in urls {
            group.addTask { 
                let data = try await fetchData(from: url)
                return UIImage(data: data)!
            }
        }
        for try await image in group {
            images.append(image)
        }
        return images
    }
}
  • withThrowingTaskGroup propagates the first error, cancelling remaining tasks.
  • Iterating over the group yields each child’s return value.

Cancellation and Timeouts

Checking for Cancellation

Swift concurrency lets you cooperative-cancel long-running tasks:

swiftCopyEditfunc performHeavyComputation() async throws -> Result {
    for chunk in dataChunks {
        try Task.checkCancellation()
        process(chunk)
    }
    return finalResult
}
  • Task.checkCancellation() throws CancellationError if the task was cancelled.

Adding Timeouts

Wrap work in withTimeout or manually:

swiftCopyEditfunc fetchWithTimeout() async throws -> Data {
    try await withThrowingTaskGroup(of: Data.self) { group in
        group.addTask { try await fetchData() }
        group.addTask {
            try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
            throw URLError(.timedOut)
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

This pattern races your fetch against a timeout task, cancelling whichever loses the race

Actors: Safe Shared State

Defining an Actor

Actors protect mutable state by serializing access:

swiftCopyEditactor SessionManager {
    private var token: String?
    
    func updateToken(_ newToken: String) {
        token = newToken
    }
    
    func getToken() -> String? {
        token
    }
}
  • actor functions like a class but enqueues accesses on a private serial executor.
  • No data races; you can await actor methods from any context.

Using an Actor in Your App

swiftCopyEditlet session = SessionManager()

Task {
    await session.updateToken("abc123")
}

Task {
    if let token = await session.getToken() {
        print("Current token: \(token)")
    }
}

Actors ensure consistency without locks or manual synchronization.

Integrating with Existing Code and Combine

If you have legacy Combine pipelines, you can bridge them into Swift Concurrency:

swiftCopyEditextension URLSession {
    func dataAsync(for url: URL) async throws -> (Data, URLResponse) {
        try await withCheckedThrowingContinuation { continuation in
            let task = dataTask(with: url) { data, response, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: (data!, response!))
                }
            }
            task.resume()
        }
    }
}
  • Continuations (withCheckedThrowingContinuation) convert callback-based APIs into async functions.
  • This helps you gradually migrate to Swift Concurrency without rewriting everything at once.

Best Practices for Swift Concurrency

  • Favor Structured Concurrency: Use TaskGroup over spawning detached Tasks when tasks are related.
  • Check for Cancellation: Cancellable APIs should periodically call Task.checkCancellation().
  • Avoid Blocking Inside Async: Don’t use DispatchQueue.sync or Thread.sleep—use Task.sleep.
  • Minimize Actor Isolation: Keep actor-isolated methods coarse-grained to avoid excessive suspension points.
  • Graceful Error Handling: Use withThrowingTaskGroup for concurrent tasks that may fail, and handle errors at the group boundary.

Migrating Legacy Code: A Step-by-Step Plan

  1. Identify Hotspots: Look for complex callback chains and Combine publishers driving UI updates.
  2. Wrap Callbacks with Continuations: Convert to async using checked continuations.
  3. Refactor Networking Layers: Replace completion-handler-based network calls with async/await.
  4. Introduce Tasks Near Entry Points: Wrap view-controller event handlers (e.g., button taps) in Task { … }.
  5. Adopt Structured Concurrency: Group related tasks to manage lifecycle and cancellations.
  6. Swap Synchronized State for Actors: Replace manually locked shared state with actors.

Conclusion

Swift Concurrency’s async/await, structured concurrency, and actors represent a seismic shift in how we write asynchronous code for iOS. By adopting these features, you’ll achieve more readable, maintainable, and robust apps—free from callback hell and unpredictable threading bugs. Start small by converting a single network call, then gradually embrace TaskGroups and actors to manage complex workflows and shared state. Before long, you’ll wonder how you ever lived without this powerful, modern concurrency model.

Let's connect on TikTok

Join our newsletter to stay updated

Sydney Based Software Solutions Professional who is crafting exceptional systems and applications to solve a diverse range of problems for the past 10 years.

Share the Post

Related Posts