Getting Started with Swift Async/Await

2 min read

Swift’s async/await syntax revolutionizes how we write asynchronous code, making it more readable and less error-prone than traditional callback-based approaches. Let’s explore how to leverage these powerful features in your iOS applications.

Understanding the Basics

Before async/await, handling asynchronous operations often led to “callback hell”:

func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: userURL) { data, _, error in
        if let error = error {
            completion(.failure(error))
            return
        }

        guard let data = data else {
            completion(.failure(NetworkError.noData))
            return
        }

        do {
            let user = try JSONDecoder().decode(User.self, from: data)
            completion(.success(user))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

With async/await, the same code becomes much cleaner:

func fetchUser() async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: userURL)
    return try JSONDecoder().decode(User.self, from: data)
}

Creating Async Functions

To create an async function, simply add the async keyword:

func performTimeConsumingTask() async -> String {
    // Simulate work
    try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
    return "Task completed!"
}

Calling Async Functions

You can call async functions using await:

Task {
    let result = await performTimeConsumingTask()
    print(result) // Prints: Task completed!
}

Error Handling

Combine async with throws for functions that can fail:

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError
}

struct NetworkManager {
    func fetch<T: Decodable>(_ type: T.Type, from urlString: String) async throws -> T {
        guard let url = URL(string: urlString) else {
            throw NetworkError.invalidURL
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(type, from: data)
    }
}

Concurrent Operations

Execute multiple async operations concurrently using async let:

func fetchUserData() async throws -> (User, [Post], [Friend]) {
    async let user = fetchUser()
    async let posts = fetchPosts()
    async let friends = fetchFriends()

    return try await (user, posts, friends)
}

Task Groups

For dynamic concurrency, use task groups:

func downloadImages(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)
                return UIImage(data: data)
            }
        }

        var images: [UIImage] = []
        for try await image in group {
            if let image = image {
                images.append(image)
            }
        }
        return images
    }
}

MainActor and UI Updates

Use @MainActor to ensure UI updates happen on the main thread:

@MainActor
class ViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false

    func loadUsers() async {
        isLoading = true
        defer { isLoading = false }

        do {
            users = try await networkManager.fetch([User].self, from: apiURL)
        } catch {
            print("Failed to load users: \(error)")
        }
    }
}

Best Practices

1. Avoid Blocking the Main Thread

// Bad
Task { @MainActor in
    let data = try await expensiveOperation() // Blocks UI
    updateUI(with: data)
}

// Good
Task {
    let data = try await expensiveOperation()
    await MainActor.run {
        updateUI(with: data)
    }
}

2. Handle Cancellation

func processLargeDataset() async throws -> ProcessedData {
    var processedItems: [Item] = []

    for item in largeDataset {
        try Task.checkCancellation() // Check if task was cancelled
        let processed = try await process(item)
        processedItems.append(processed)
    }

    return ProcessedData(items: processedItems)
}

3. Use Structured Concurrency

func performComplexOperation() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask { try await subtask1() }
        group.addTask { try await subtask2() }
        group.addTask { try await subtask3() }

        try await group.waitForAll()
    }
}

Conclusion

Swift’s async/await transforms asynchronous programming from a complex challenge into a straightforward task. By adopting these patterns, you’ll write more maintainable, readable, and robust code. Start small by converting simple callbacks to async functions, then gradually adopt more advanced patterns like task groups and actors.

Remember: async/await isn’t just about syntax—it’s about writing better, safer concurrent code that’s easier to reason about and debug.