A comprehensive Swift async/await learning app with interactive cooking examples.
This app teaches Swift's async/await concurrency through cooking metaphors and interactive examples. Perfect for developers who want to learn modern Swift concurrency in a fun, hands-on way!
- Lesson 0: The Journey - From callback hell to async paradise
- Lesson 1: Basic Timer - Simple async/await with cooking timers
- Lesson 2: Concurrent Tasks - Multiple cooking tasks simultaneously
- Lesson 3: Sequential Steps - Chaining cooking operations
- Lesson 4: Error Handling - Dealing with cooking mistakes
- Lesson 5: Actors - Thread-safe kitchen management
- Lesson 6: TaskGroup - Managing multiple dishes
func fetchData() async -> String {
// Async work here
return "Data"
}func fetchData() async throws -> String {
// Can throw errors
return "Data"
}let result = await fetchData()
let result = try await fetchData()Task {
await doSomething()
}
Task {
try await doSomethingThrowing()
}Task(priority: .high) {
await importantWork()
}Task.detached {
await backgroundWork()
}let task = Task {
await longRunningWork()
}
task.cancel()
// Check for cancellation
if Task.isCancelled {
return
}async let data1 = fetchData1()
async let data2 = fetchData2()
let results = await (data1, data2)await withTaskGroup(of: String.self) { group in
group.addTask { await fetchData1() }
group.addTask { await fetchData2() }
for await result in group {
print(result)
}
}try await withThrowingTaskGroup(of: String.self) { group in
group.addTask { try await fetchData1() }
group.addTask { try await fetchData2() }
for try await result in group {
print(result)
}
}@MainActor
func updateUI() {
// Always runs on main thread
label.text = "Updated"
}@MainActor
class ViewModel: ObservableObject {
@Published var data = ""
@Published var isLoading = false
func loadData() async {
isLoading = true
// This automatically runs on main thread
// No need for MainActor.run!
let result = await fetchDataFromBackground()
data = result // Safe UI update
isLoading = false
}
}// Only use when you can't make the whole function @MainActor
func backgroundTask() async {
let result = await heavyComputation()
// Switch to main thread for UI update
await MainActor.run {
self.isLoading = false
self.data = result
}
}| Scenario | Use @MainActor | Use MainActor.run |
|---|---|---|
| UI Classes (ViewModels, Views) | ✅ Always | ❌ Never needed |
| Mixed UI/Background work | ✅ Preferred | |
| Pure background functions | ❌ No | ✅ For UI updates |
| SwiftUI Views | ✅ Automatic | ❌ Never needed |
@MainActor
class PhotoGalleryViewModel: ObservableObject {
@Published var photos: [Photo] = []
@Published var isLoading = false
@Published var errorMessage: String?
// Everything here runs on main thread automatically
func loadPhotos() async {
isLoading = true
errorMessage = nil
do {
// This background work happens automatically
photos = try await PhotoService.fetchPhotos()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
// No MainActor.run needed! 🎉
}
func refreshPhotos() async {
await loadPhotos() // Still on main thread
}
}// Background service that occasionally needs UI updates
class BackgroundSyncService {
weak var delegate: SyncDelegate?
func performSync() async {
// Heavy background work
let results = await processLargeDataset()
// Need to update UI delegate on main thread
await MainActor.run {
delegate?.syncCompleted(results)
}
}
}
// Or when bridging with non-async code
func legacyCallback() {
Task {
let data = await fetchData()
await MainActor.run {
// Update legacy UI components
self.tableView.reloadData()
}
}
}do {
let result = try await fetchData()
print(result)
} catch {
print("Error: \(error)")
}do {
let result = try await fetchData()
} catch NetworkError.noConnection {
showOfflineMessage()
} catch NetworkError.timeout {
showTimeoutMessage()
} catch {
showGenericError(error)
}let result = try? await fetchData()
// result is nil if error occursactor Counter {
private var value = 0
func increment() {
value += 1
}
func getValue() -> Int {
return value
}
}let counter = Counter()
await counter.increment()
let value = await counter.getValue()actor DataManager {
private var data: [String] = []
nonisolated func getCount() -> Int {
// Can be called without await
return data.count
}
}func fetchUser(id: String) async throws -> User {
let url = URL(string: "api/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}struct ContentView: View {
@State private var data = ""
var body: some View {
Text(data)
.task {
data = await fetchData()
}
}
}for await line in URL.lines {
print(line)
}
// Custom async sequence
for await value in asyncSequence {
process(value)
}// ✅ Good
async let data1 = fetch1()
async let data2 = fetch2()
await (data1, data2)
// ❌ Avoid
Task.detached { await fetch1() }
Task.detached { await fetch2() }func longTask() async {
for i in 0..<1000 {
if Task.isCancelled {
return
}
await doWork(i)
}
}@MainActor
class ViewModel: ObservableObject {
@Published var isLoading = false
func loadData() async {
isLoading = true
defer { isLoading = false }
// Load data...
}
}// ❌ Bad - blocks main thread
DispatchQueue.main.sync {
await fetchData()
}
// ✅ Good
await MainActor.run {
updateUI()
}// ❌ Bad - unhandled errors
let data = try await fetchData()
// ✅ Good
do {
let data = try await fetchData()
} catch {
handleError(error)
}// ❌ Bad - loses context
Task.detached {
await doWork()
}
// ✅ Good - inherits context
Task {
await doWork()
}// ❌ Old: Closure-based
func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} else if let data = data {
let result = String(data: data, encoding: .utf8) ?? ""
completion(.success(result))
}
}.resume()
}
// ✅ New: Async/await
func fetchData() async throws -> String {
let (data, _) = try await URLSession.shared.data(from: url)
return String(data: data, encoding: .utf8) ?? ""
}// ❌ Old: Nested callbacks
func loadUserProfile(completion: @escaping (UserProfile?, Error?) -> Void) {
fetchBasicInfo { basicInfo, error in
guard let basicInfo = basicInfo, error == nil else {
completion(nil, error)
return
}
fetchPreferences { preferences, error in
guard let preferences = preferences, error == nil else {
completion(nil, error)
return
}
fetchFriends { friends, error in
guard let friends = friends, error == nil else {
completion(nil, error)
return
}
let profile = UserProfile(basic: basicInfo, preferences: preferences, friends: friends)
completion(profile, nil)
}
}
}
}
// ✅ New: Clean async/await
func loadUserProfile() async throws -> UserProfile {
async let basicInfo = fetchBasicInfo()
async let preferences = fetchPreferences()
async let friends = fetchFriends()
return try await UserProfile(
basic: basicInfo,
preferences: preferences,
friends: friends
)
}// ❌ Old: Legacy callback API
func legacyNetworkCall(completion: @escaping (Data?, Error?) -> Void) {
// Some old networking code
}
// ✅ New: Wrapped in async/await
func modernNetworkCall() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
legacyNetworkCall { data, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: NetworkError.noData)
}
}
}
}// ❌ Old: Combine Publisher
func fetchDataPublisher() -> AnyPublisher<String, Error> {
URLSession.shared.dataTaskPublisher(for: url)
.map { String(data: $0.data, encoding: .utf8) ?? "" }
.eraseToAnyPublisher()
}
// Usage with Combine
fetchDataPublisher()
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
print("Error: \(error)")
}
},
receiveValue: { data in
print("Data: \(data)")
}
)
.store(in: &cancellables)
// ✅ New: Async/await
func fetchData() async throws -> String {
let (data, _) = try await URLSession.shared.data(from: url)
return String(data: data, encoding: .utf8) ?? ""
}
// Usage with async/await
do {
let data = try await fetchData()
print("Data: \(data)")
} catch {
print("Error: \(error)")
}// ❌ Old: Complex Combine chain
func loadUserDataPublisher() -> AnyPublisher<UserData, Error> {
fetchUserIdPublisher()
.flatMap { userId in
fetchUserProfilePublisher(userId: userId)
}
.flatMap { profile in
fetchUserPostsPublisher(userId: profile.id)
.map { posts in
UserData(profile: profile, posts: posts)
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
// ✅ New: Sequential async/await
func loadUserData() async throws -> UserData {
let userId = try await fetchUserId()
let profile = try await fetchUserProfile(userId: userId)
let posts = try await fetchUserPosts(userId: profile.id)
return UserData(profile: profile, posts: posts)
}
// Or concurrent if operations are independent:
func loadUserDataConcurrent() async throws -> UserData {
let userId = try await fetchUserId()
async let profile = fetchUserProfile(userId: userId)
async let posts = fetchUserPosts(userId: userId)
return try await UserData(profile: profile, posts: posts)
}// ❌ Old: Combine with side effects
func loadDataWithUpdatesPublisher() -> AnyPublisher<String, Error> {
fetchDataPublisher()
.handleEvents(
receiveSubscription: { _ in
DispatchQueue.main.async {
self.isLoading = true
}
},
receiveOutput: { _ in
DispatchQueue.main.async {
self.lastUpdated = Date()
}
},
receiveCompletion: { _ in
DispatchQueue.main.async {
self.isLoading = false
}
}
)
.eraseToAnyPublisher()
}
// ✅ New: Async/await with explicit updates
@MainActor
func loadDataWithUpdates() async throws -> String {
isLoading = true
defer { isLoading = false }
let data = try await fetchData()
lastUpdated = Date()
return data
}// ❌ Old: Combine with SwiftUI
struct ContentView: View {
@State private var data = ""
@State private var cancellables = Set<AnyCancellable>()
var body: some View {
Text(data)
.onAppear {
fetchDataPublisher()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { _ in },
receiveValue: { newData in
self.data = newData
}
)
.store(in: &cancellables)
}
}
}
// ✅ New: Async/await with SwiftUI
struct ContentView: View {
@State private var data = ""
var body: some View {
Text(data)
.task {
do {
data = try await fetchData()
} catch {
print("Error: \(error)")
}
}
}
}// ❌ Old: Combine-based ViewModel
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
private var cancellables = Set<AnyCancellable>()
func loadUser() {
isLoading = true
fetchUserPublisher()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
receiveValue: { [weak self] user in
self?.user = user
}
)
.store(in: &cancellables)
}
}
// ✅ New: Async/await ViewModel
@MainActor
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
func loadUser() async {
isLoading = true
defer { isLoading = false }
do {
user = try await fetchUser()
} catch {
print("Error loading user: \(error)")
}
}
}- Replace completion handlers with
async throws - Use
withCheckedContinuationfor legacy APIs - Remove manual thread switching
- Simplify error handling with do-catch
- Use structured concurrency for multiple operations
- Replace Publishers with async functions
- Convert
.flatMapchains to sequentialawait - Use
async letfor concurrent operations - Move side effects to explicit locations
- Use
@MainActorinstead of.receive(on: DispatchQueue.main) - Replace
.sinkwith do-catch blocks
- Replace
.onAppear+ Publisher with.task - Use
@MainActoron ViewModels - Remove
AnyCancellablestorage - Simplify state management
// ❌ Wrong conversion: Missing @MainActor
func loadData() async -> String {
let (data, _) = try await URLSession.shared.data(from: url)
let result = processData(data)
self.updateUI(result) // ⚠️ Might not be on main thread!
return result
}
// ✅ Correct: Use @MainActor
@MainActor
func loadData() async -> String {
let (data, _) = try await URLSession.shared.data(from: url)
let result = processData(data)
self.updateUI(result) // ✅ Guaranteed main thread
return result
}// ❌ Wrong conversion: No cancellation checks
func longRunningTask() async throws -> String {
for i in 0..<1000000 {
doWork(i) // Heavy work without cancellation check
}
return "Done"
}
// ✅ Correct: Check for cancellation
func longRunningTask() async throws -> String {
for i in 0..<1000000 {
if Task.isCancelled {
throw CancellationError()
}
doWork(i)
}
return "Done"
}// ❌ Wrong conversion: Generic error handling
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
// ⚠️ Lost specific error types and HTTP status handling!
}
// ✅ Correct: Preserve error context
func fetchUser() async throws -> User {
do {
let (data, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 404 {
throw NetworkError.userNotFound
} else if httpResponse.statusCode >= 400 {
throw NetworkError.serverError(httpResponse.statusCode)
}
}
return try JSONDecoder().decode(User.self, from: data)
} catch let urlError as URLError {
throw NetworkError.networkFailure(urlError)
}
}// ❌ Wrong: Can resume multiple times
func convertLegacyAPI() async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
legacyAPI { result, error in
if let error = error {
continuation.resume(throwing: error)
}
if let result = result {
continuation.resume(returning: result) // ⚠️ Might be called after error!
}
}
}
}
// ✅ Correct: Single resume path
func convertLegacyAPI() async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
legacyAPI { result, error in
if let error = error {
continuation.resume(throwing: error)
} else if let result = result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: APIError.noData)
}
}
}
}// ❌ Wrong conversion: Made it sequential
func loadUserData() async throws -> UserData {
let profile = try await fetchProfile() // Wait for this...
let posts = try await fetchPosts() // Then this...
let friends = try await fetchFriends() // Then this...
return UserData(profile: profile, posts: posts, friends: friends)
}
// ✅ Correct: Keep it concurrent
func loadUserData() async throws -> UserData {
async let profile = fetchProfile()
async let posts = fetchPosts()
async let friends = fetchFriends()
return try await UserData(
profile: profile,
posts: posts,
friends: friends
)
}actor DataManager {
private var cache: [String: Data] = [:]
func getData(key: String) -> Data? {
return cache[key]
}
}
// ❌ Wrong: Forgot await
func useDataManager() async {
let manager = DataManager()
let data = manager.getData(key: "test") // ⚠️ Compiler error!
}
// ✅ Correct: Use await
func useDataManager() async {
let manager = DataManager()
let data = await manager.getData(key: "test")
}class ViewModel {
var data: String = ""
// ❌ Wrong: Potential retain cycle
func loadData() {
Task {
let result = await fetchData()
self.data = result // Strong reference to self
}
}
// ✅ Correct: Use weak self or @MainActor
@MainActor
func loadData() async {
data = await fetchData() // No retain cycle with @MainActor
}
}// ❌ Wrong: Using onAppear with Task
struct ContentView: View {
@State private var data = ""
var body: some View {
Text(data)
.onAppear {
Task {
data = await loadData()
}
}
}
}
// ✅ Correct: Use .task modifier
struct ContentView: View {
@State private var data = ""
var body: some View {
Text(data)
.task {
data = await loadData()
}
}
}// ❌ Wrong: Hard to test
func loadUserData() async throws -> UserData {
let data = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(UserData.self, from: data.0)
}
// ✅ Correct: Dependency injection for testing
protocol NetworkService {
func data(from url: URL) async throws -> (Data, URLResponse)
}
func loadUserData(networkService: NetworkService = URLSession.shared) async throws -> UserData {
let (data, _) = try await networkService.data(from: url)
return try JSONDecoder().decode(UserData.self, from: data)
}- Threading: Add
@MainActorwhere needed - Cancellation: Check
Task.isCancelledin long operations - Error Handling: Preserve specific error types
- Concurrency: Use
async letfor parallel work - Continuations: Ensure single resume call
- Memory: Avoid retain cycles with
[weak self]or@MainActor - SwiftUI: Use
.taskinstead of.onAppear - Testing: Make dependencies injectable
- Actor Isolation: Add
awaitfor actor calls - Performance: Consider
Task.detachedfor heavy CPU work
Expression is 'async' but is not marked with 'await'
// Common scenario: Converting URLSession callback to async
class NetworkManager {
// ❌ Error: Missing await
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = URLSession.shared.data(from: url) // Error here!
return try JSONDecoder().decode(User.self, from: data)
}
// ✅ Fix: Add await
func fetchUser(id: String) 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)
}
}'await' in a function that does not support concurrency
// Common scenario: SwiftUI ViewModel loading data
@MainActor
class UserProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
// ❌ Error: viewDidLoad is not async
func viewDidLoad() {
isLoading = true
user = await NetworkManager.shared.fetchUser(id: "123") // Error here!
isLoading = false
}
// ✅ Fix 1: Make function async
func viewDidLoad() async {
isLoading = true
user = try? await NetworkManager.shared.fetchUser(id: "123")
isLoading = false
}
// ✅ Fix 2: Use Task wrapper
func viewDidLoad() {
Task {
isLoading = true
user = try? await NetworkManager.shared.fetchUser(id: "123")
isLoading = false
}
}
}
// Usage in SwiftUI:
struct UserProfileView: View {
@StateObject private var viewModel = UserProfileViewModel()
var body: some View {
VStack {
if let user = viewModel.user {
Text(user.name)
}
}
.task {
await viewModel.viewDidLoad() // Use .task for async functions
}
}
}Cannot pass function of type '() async -> Void' to parameter expecting synchronous function type
// Common scenario: SwiftUI Button actions and UIKit target-action
class DataViewController: UIViewController {
@Published var data: [Item] = []
// Async function to load data
func loadData() async {
data = try? await APIService.fetchItems()
}
override func viewDidLoad() {
super.viewDidLoad()
// ❌ Error: Cannot pass async function to UIButton action
let refreshButton = UIButton()
refreshButton.addTarget(self, action: #selector(loadData), for: .touchUpInside)
// Error: loadData is async, but @objc methods must be sync
}
// ✅ Fix: Create sync wrapper
@objc func refreshButtonTapped() {
Task {
await loadData()
}
}
override func viewDidLoad() {
super.viewDidLoad()
let refreshButton = UIButton()
refreshButton.addTarget(self, action: #selector(refreshButtonTapped), for: .touchUpInside)
}
}
// SwiftUI Example:
struct ContentView: View {
@State private var items: [Item] = []
func loadItems() async {
items = try? await APIService.fetchItems()
}
var body: some View {
VStack {
List(items) { item in
Text(item.name)
}
// ❌ Error: Cannot pass async function directly
// Button("Refresh", action: loadItems)
// ✅ Fix: Use closure with Task
Button("Refresh") {
Task {
await loadItems()
}
}
// ✅ Alternative: Use .refreshable modifier
List(items) { item in
Text(item.name)
}
.refreshable {
await loadItems()
}
}
}
}Actor-isolated property 'data' can not be referenced from a non-isolated context
// Common scenario: Thread-safe cache implementation
actor ImageCache {
private var cache: [String: UIImage] = [:]
var cacheSize: Int {
return cache.count
}
func store(image: UIImage, forKey key: String) {
cache[key] = image
}
func image(forKey key: String) -> UIImage? {
return cache[key]
}
}
@MainActor
class ImageLoader: ObservableObject {
@Published var loadedImage: UIImage?
private let cache = ImageCache()
// ❌ Error: Accessing actor property without await
func loadImage(from url: URL) {
// Check cache size for debugging
print("Cache size: \(cache.cacheSize)") // Error here!
// Try to get cached image
if let cachedImage = cache.image(forKey: url.absoluteString) { // Error here!
loadedImage = cachedImage
return
}
// Load from network...
}
// ✅ Fix: Add await for actor calls
func loadImage(from url: URL) async {
// Check cache size for debugging
print("Cache size: \(await cache.cacheSize)")
// Try to get cached image
if let cachedImage = await cache.image(forKey: url.absoluteString) {
loadedImage = cachedImage
return
}
// Load from network
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
await cache.store(image: image, forKey: url.absoluteString)
loadedImage = image
}
} catch {
print("Failed to load image: \(error)")
}
}
}
// Usage in SwiftUI:
struct AsyncImageView: View {
let url: URL
@StateObject private var loader = ImageLoader()
var body: some View {
Group {
if let image = loader.loadedImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
ProgressView()
}
}
.task {
await loader.loadImage(from: url)
}
}
}Cannot use mutating member on immutable value: 'self' is immutable
struct DataLoader {
var isLoading = false
// ❌ Error: Mutating async function
func loadData() async {
isLoading = true // Error: Cannot mutate
}
}
// ✅ Fix: Add mutating keyword
struct DataLoader {
var isLoading = false
mutating func loadData() async {
isLoading = true
}
}
// ✅ Better: Use class with @MainActor
@MainActor
class DataLoader: ObservableObject {
@Published var isLoading = false
func loadData() async {
isLoading = true // Works fine
}
}Call to main actor-isolated instance method 'updateUI' in a synchronous nonisolated context
@MainActor
class ViewModel {
func updateUI() { }
}
// ❌ Error: Calling MainActor method from background
func backgroundWork() {
let vm = ViewModel()
vm.updateUI() // Error: Not on main actor
}
// ✅ Fix: Use await or MainActor.run
func backgroundWork() async {
let vm = ViewModel()
await vm.updateUI()
}
// Or:
func backgroundWork() {
Task { @MainActor in
let vm = ViewModel()
vm.updateUI()
}
}Cannot convert value of type 'Task<Void, Never>' to expected argument type 'AnyCancellable'
class ViewModel {
private var cancellables = Set<AnyCancellable>()
// ❌ Error: Task is not AnyCancellable
func loadData() {
Task {
await fetchData()
}
.store(in: &cancellables) // Error: Task doesn't have store method
}
}
// ✅ Fix: Store Task reference separately or use @MainActor
@MainActor
class ViewModel: ObservableObject {
private var currentTask: Task<Void, Never>?
func loadData() {
currentTask?.cancel()
currentTask = Task {
await fetchData()
}
}
}Mutation of captured var 'self' in concurrently-executing code
class ViewModel {
var data: String = ""
// ❌ Error: Mutating self in concurrent context
func loadData() {
Task {
self.data = await fetchData() // Error: Concurrent mutation
}
}
}
// ✅ Fix: Use @MainActor
@MainActor
class ViewModel: ObservableObject {
@Published var data: String = ""
func loadData() {
Task {
data = await fetchData() // Works fine
}
}
}Cannot call mutating async function 'loadData' on actor-isolated property 'manager'
actor DataManager {
var cache: [String: Data] = [:]
mutating func updateCache() async {
// Mutating function
}
}
class ViewModel {
let manager = DataManager()
// ❌ Error: Cannot call mutating function on actor
func update() async {
await manager.updateCache() // Error: mutating function
}
}
// ✅ Fix: Remove mutating (actors handle isolation automatically)
actor DataManager {
var cache: [String: Data] = [:]
func updateCache() async { // Remove mutating
cache["key"] = Data() // This is safe in actors
}
}Generic parameter 'Failure' could not be inferred
// ❌ Error: Converting Publisher without specifying error type
func convertPublisher() async -> String {
return await withCheckedContinuation { continuation in
publisher // Error: Cannot infer Failure type
.sink { completion in
// Handle completion
} receiveValue: { value in
continuation.resume(returning: value)
}
}
}
// ✅ Fix: Specify error type explicitly
func convertPublisher() async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
publisher
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
continuation.resume(throwing: error)
}
},
receiveValue: { value in
continuation.resume(returning: value)
}
)
}
}| Error Pattern | Quick Fix |
|---|---|
Missing await |
Add await before async function calls |
| Function not async | Add async to function signature |
| Sync context calling async | Wrap in Task { } |
| Actor isolation | Add await for actor calls |
| MainActor violations | Use @MainActor or MainActor.run |
| Mutating struct | Add mutating or use class |
| Task storage | Store Task references, not in AnyCancellable |
| Concurrent mutations | Use @MainActor for UI classes |
| Generic inference | Specify error types explicitly |
- Start with @MainActor: Mark UI classes with
@MainActorfirst - Add async gradually: Convert functions one at a time
- Use Task for bridging: Wrap async calls in
Task { }when needed - Check actor isolation: Remember
awaitfor actor calls - Specify error types: Be explicit with generic parameters
- Use Xcode suggestions: Let Xcode guide you with fix-it suggestions
| Feature | async let |
TaskGroup |
|---|---|---|
| Number of tasks | Fixed at compile time | Dynamic at runtime |
| Task types | Can be different types | Must be same type |
| Error handling | Individual try/catch | Centralized handling |
| Cancellation | Automatic with parent | Manual control |
| Performance | Slightly faster | More flexible |
| Use case | Known, heterogeneous tasks | Unknown count, homogeneous tasks |
Use async let when you have a fixed number of different tasks known at compile time:
// ✅ Perfect for async let: Fixed, different types
func loadUserProfile(userId: String) async throws -> UserProfile {
async let user = fetchUser(userId) // Returns User
async let posts = fetchUserPosts(userId) // Returns [Post]
async let followers = fetchFollowers(userId) // Returns [Follower]
async let settings = fetchSettings(userId) // Returns Settings
return UserProfile(
user: try await user,
posts: try await posts,
followers: try await followers,
settings: try await settings
)
}
// ✅ Good: Known number of API calls
func loadDashboard() async throws -> Dashboard {
async let weather = fetchWeather()
async let news = fetchNews()
async let stocks = fetchStocks()
return Dashboard(
weather: try await weather,
news: try await news,
stocks: try await stocks
)
}Use TaskGroup when you have a dynamic number of similar tasks or need more control:
// ✅ Perfect for TaskGroup: Dynamic count
func downloadImages(urls: [URL]) async throws -> [UIImage] {
return try await withThrowingTaskGroup(of: UIImage.self) { group in
// Don't know how many URLs at compile time
for url in urls {
group.addTask {
try await downloadImage(from: url)
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
// ✅ Good: Processing array of items
func processFiles(filePaths: [String]) async throws -> [ProcessedFile] {
try await withThrowingTaskGroup(of: ProcessedFile.self) { group in
for path in filePaths {
group.addTask {
try await processFile(at: path)
}
}
var results: [ProcessedFile] = []
for try await result in group {
results.append(result)
}
return results
}
}// ✅ TaskGroup: All tasks return same type
func fetchMultipleUsers(userIds: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for userId in userIds {
group.addTask {
try await fetchUser(userId)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
// ❌ async let would be awkward here:
func fetchMultipleUsersAwkward(userIds: [String]) async throws -> [User] {
// This doesn't work - can't create dynamic async let
// async let user1 = fetchUser(userIds[0]) // What if array is empty?
// async let user2 = fetchUser(userIds[1]) // What if only 1 element?
// ...
}// ✅ TaskGroup: Sophisticated error handling
func fetchWithRetries(urls: [URL]) async -> [Result<Data, Error>] {
await withTaskGroup(of: Result<Data, Error>.self) { group in
for url in urls {
group.addTask {
// Custom retry logic per task
for attempt in 1...3 {
do {
let (data, _) = try await URLSession.shared.data(from: url)
return .success(data)
} catch {
if attempt == 3 {
return .failure(error)
}
try? await Task.sleep(nanoseconds: UInt64(attempt) * 1_000_000_000)
}
}
return .failure(URLError(.unknown))
}
}
var results: [Result<Data, Error>] = []
for await result in group {
results.append(result)
}
return results
}
}// ✅ TaskGroup: Cancel remaining tasks when one succeeds
func findFirstAvailableServer(servers: [URL]) async throws -> URL {
try await withThrowingTaskGroup(of: URL.self) { group in
for server in servers {
group.addTask {
try await checkServerAvailability(server)
return server
}
}
// Return first successful result and cancel others
for try await availableServer in group {
group.cancelAll() // Cancel remaining tasks
return availableServer
}
throw NetworkError.noServersAvailable
}
}
// ✅ TaskGroup: Limit concurrent operations
func downloadWithLimit(urls: [URL], maxConcurrent: Int) async throws -> [Data] {
try await withThrowingTaskGroup(of: (Int, Data).self) { group in
var results: [Data?] = Array(repeating: nil, count: urls.count)
var currentIndex = 0
// Start initial batch
for _ in 0..<min(maxConcurrent, urls.count) {
let index = currentIndex
currentIndex += 1
group.addTask {
let (data, _) = try await URLSession.shared.data(from: urls[index])
return (index, data)
}
}
// Process results and add new tasks
for try await (index, data) in group {
results[index] = data
// Add next task if available
if currentIndex < urls.count {
let nextIndex = currentIndex
currentIndex += 1
group.addTask {
let (data, _) = try await URLSession.shared.data(from: urls[nextIndex])
return (nextIndex, data)
}
}
}
return results.compactMap { $0 }
}
}
// 🖼️ Real Example: Download 1000 images with only 5 concurrent downloads
func downloadImages(imageUrls: [URL]) async throws -> [UIImage] {
let maxConcurrent = 5
return try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
var images: [UIImage?] = Array(repeating: nil, count: imageUrls.count)
var currentIndex = 0
// Start first 5 downloads
for _ in 0..<min(maxConcurrent, imageUrls.count) {
let index = currentIndex
currentIndex += 1
group.addTask {
print("📥 Starting download \(index + 1)/\(imageUrls.count)")
let (data, _) = try await URLSession.shared.data(from: imageUrls[index])
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
print("✅ Completed download \(index + 1)/\(imageUrls.count)")
return (index, image)
}
}
// As each download completes, start the next one
for try await (completedIndex, image) in group {
images[completedIndex] = image
// Start next download if more URLs remain
if currentIndex < imageUrls.count {
let nextIndex = currentIndex
currentIndex += 1
group.addTask {
print("📥 Starting download \(nextIndex + 1)/\(imageUrls.count)")
let (data, _) = try await URLSession.shared.data(from: imageUrls[nextIndex])
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
print("✅ Completed download \(nextIndex + 1)/\(imageUrls.count)")
return (nextIndex, image)
}
}
}
return images.compactMap { $0 }
}
}
// 📱 Usage Example:
func loadPhotoGallery() async {
let imageUrls = generateImageUrls(count: 1000) // Your 1000 image URLs
do {
print("🚀 Starting download of \(imageUrls.count) images (max 5 concurrent)")
let images = try await downloadImages(imageUrls: imageUrls)
print("🎉 Downloaded \(images.count) images successfully!")
// Update UI with images
await MainActor.run {
self.photoGallery = images
}
} catch {
print("❌ Download failed: \(error)")
}
}// ✅ async let: Fixed, different data types
func loadFeedData(userId: String) async throws -> FeedData {
async let profile = fetchUserProfile(userId) // User
async let timeline = fetchTimeline(userId) // [Post]
async let notifications = fetchNotifications(userId) // [Notification]
async let suggestions = fetchSuggestions(userId) // [User]
return FeedData(
profile: try await profile,
timeline: try await timeline,
notifications: try await notifications,
suggestions: try await suggestions
)
}
// ✅ TaskGroup: Dynamic posts with images
func loadTimelineWithImages(posts: [Post]) async throws -> [PostWithImage] {
try await withThrowingTaskGroup(of: PostWithImage.self) { group in
for post in posts {
group.addTask {
let imageData = try await downloadImage(from: post.imageURL)
return PostWithImage(post: post, imageData: imageData)
}
}
var postsWithImages: [PostWithImage] = []
for try await postWithImage in group {
postsWithImages.append(postWithImage)
}
return postsWithImages
}
}- Slightly faster: Less overhead
- Compile-time optimization: Better compiler optimizations
- Simpler memory management: Automatic cleanup
- Memory efficient: Can process results as they arrive
- Flexible cancellation: Cancel individual or all tasks
- Better for large datasets: Doesn't hold all results in memory
Do you know the exact number of tasks at compile time?
├─ YES: Do the tasks return different types?
│ ├─ YES: Use `async let` ✅
│ └─ NO: Are there more than ~5 tasks?
│ ├─ YES: Consider `TaskGroup` for readability
│ └─ NO: `async let` is fine ✅
└─ NO: Use `TaskGroup` ✅
Do you need advanced error handling or cancellation?
└─ YES: Use `TaskGroup` ✅
Do you need to limit concurrent operations?
└─ YES: Use `TaskGroup` ✅
Is the number of tasks very large (>100)?
└─ YES: Use `TaskGroup` for memory efficiency ✅
- Start with
async letfor simple, fixed scenarios - Use
TaskGroupwhen you need flexibility - Consider memory usage for large datasets
- Handle errors appropriately in both approaches
- Test cancellation behavior in your specific use case
Here's a real-world example showing a complete migration from callbacks to async/await:
// Old callback-based approach
class UserService {
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
let url = URL(string: "https://api.example.com/users/\(id)")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
guard let data = data else {
DispatchQueue.main.async {
completion(.failure(APIError.noData))
}
return
}
do {
let user = try JSONDecoder().decode(User.self, from: data)
DispatchQueue.main.async {
completion(.success(user))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}.resume()
}
}
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let userService = UserService()
func loadUser(id: String) {
isLoading = true
errorMessage = nil
userService.fetchUser(id: id) { [weak self] result in
self?.isLoading = false
switch result {
case .success(let user):
self?.user = user
case .failure(let error):
self?.errorMessage = error.localizedDescription
}
}
}
}// Modern async/await approach
class UserService {
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
// Handle HTTP errors
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode >= 400 {
throw APIError.httpError(httpResponse.statusCode)
}
return try JSONDecoder().decode(User.self, from: data)
}
}
@MainActor
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let userService = UserService()
func loadUser(id: String) async {
isLoading = true
errorMessage = nil
do {
user = try await userService.fetchUser(id: id)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// SwiftUI Usage
struct UserProfileView: View {
@StateObject private var viewModel = UserViewModel()
let userId: String
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView("Loading user...")
} else if let user = viewModel.user {
VStack {
Text(user.name)
.font(.title)
Text(user.email)
.foregroundColor(.secondary)
}
} else if let error = viewModel.errorMessage {
Text("Error: \(error)")
.foregroundColor(.red)
Button("Retry") {
Task {
await viewModel.loadUser(id: userId)
}
}
}
}
.task {
await viewModel.loadUser(id: userId)
}
.refreshable {
await viewModel.loadUser(id: userId)
}
}
}- ✅ Cleaner code: No nested callbacks or manual thread switching
- ✅ Better error handling: Standard do-catch blocks
- ✅ Automatic main thread:
@MainActorensures UI updates on main thread - ✅ Built-in cancellation:
.taskautomatically cancels when view disappears - ✅ SwiftUI integration:
.refreshableworks seamlessly with async functions - ✅ No memory leaks: No need for
[weak self]with@MainActor
| Symbol | Description |
|---|---|
async |
Function can be suspended |
await |
Wait for async function to complete |
Task { } |
Create new async task |
async let |
Concurrent execution |
@MainActor |
Run on main thread |
actor |
Thread-safe data container |
try await |
Async function that can throw |
.task { } |
SwiftUI async work |
- Clone this repository
- Open
AsyncAwait.xcodeprojin Xcode - Run the app on iOS Simulator or device
- Start with Lesson 0 to understand the journey from callbacks to async/await
- Progress through each lesson at your own pace
- Structured Concurrency: How Swift manages async tasks safely
- Actor Model: Thread-safe data sharing without locks
- MainActor: Ensuring UI updates happen on the main thread
- Error Handling: Proper async error management
- Task Management: Creating, canceling, and coordinating tasks
- Performance: When and how to use concurrent execution
- Swift Concurrency Documentation
- WWDC 2021: Meet async/await in Swift
- WWDC 2021: Protect mutable state with Swift actors
Happy Cooking with Async/Await! 🍳✨