Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 011 / 100

Swift async await explained in Swift, SwiftUI & iOS Apps

Modern concurrency without callbacks.

Expona Admin8 min read

The callback problem and why async/await matters

For years, iOS developers managed asynchronous work through completion handlers and delegate patterns. A simple network request would nest callbacks, making code harder to read and easier to introduce bugs. Swift async await explained in Swift, SwiftUI & iOS Apps gives us a cleaner mental model: we write asynchronous code that looks synchronous, suspends when waiting, and resumes when data arrives. This shift from "tell me when you're done" to "pause here until the result is ready" transforms how we structure concurrent work.

By the time you finish this article, you'll understand how to define async functions, call them correctly, spawn background work from UI code, and run multiple tasks in parallel. We'll walk through real production patterns you'll use in every iOS app.

Async functions — Can suspend while waiting for work

An async function is one that can suspend execution and resume later. You mark it with the async keyword in its signature. Inside an async function, you can use the await keyword to suspend at points where work is happening—network calls, file I/O, or other async operations.

Here's the classic example: fetching user data from an API.

swift
struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

func loadUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let decoder = JSONDecoder()
    return try decoder.decode(User.self, from: data)
}

Notice the function signature: async throws -> User. The async keyword tells Swift this function can suspend. The throws means it can throw errors. The return type is User, not a closure or completion handler.

Inside the function, try await URLSession.shared.data(from: url) does the heavy lifting. The await keyword says "suspend here until the network request completes and returns data." While this function is suspended, the thread is free to run other work. When the data arrives, the function resumes exactly where it left off, and execution continues to the JSON decoding.

You can also mark a function as async without throwing errors:

swift
func fetchImageData(url: URL) async -> Data? {
    do {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    } catch {
        return nil
    }
}

This pattern is common when you want to handle errors internally and return an optional or a fallback value. The key insight is that any suspension point inside an async function must use await. This makes it explicit where your function might pause.

Calling them — Use await inside an async context

You can only call an async function from within an async context. You cannot call an async function directly from a synchronous function. This is a safety feature: it prevents accidentally blocking the main thread or creating confusion about where execution pauses.

In SwiftUI views, the most common entry point is the .task modifier, which runs async work when the view appears:

swift
struct UserDetailView: View {
    @State var user: User?
    @State var isLoading = false
    @State var errorMessage: String?
    
    let userId: Int
    
    var body: some View {
        VStack {
            if isLoading {
                ProgressView()
            } else if let user = user {
                Text(user.name)
                Text(user.email)
            } else if let error = errorMessage {
                Text("Error: \(error)")
                    .foregroundColor(.red)
            }
        }
        .task {
            isLoading = true
            defer { isLoading = false }
            
            do {
                user = try await loadUser(id: userId)
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
}

The .task modifier receives a closure marked as async. Inside that closure, we can use await freely. When loadUser(id:) suspends waiting for the network, SwiftUI's runtime keeps the view responsive. Once the user data arrives, we update the @State property, and SwiftUI re-renders automatically.

Notice we set isLoading = true before the await, and use defer to guarantee we set it false afterward, even if an error occurs. This pattern keeps UI state in sync with network activity.

You can also call async functions from other async functions by chaining await:

swift
func loadUserWithPosts(id: Int) async throws -> (user: User, posts: [String]) {
    let user = try await loadUser(id: id)
    let posts = try await loadUserPosts(userId: user.id)
    return (user, posts)
}

Here, we first await the user, then await the posts. This is sequential—we wait for the user to load, then fetch posts. Sometimes that's correct. Other times, you want parallelism.

Task — Start async work from synchronous code

Not every part of your app is async. View controllers, delegates, and event handlers are often synchronous. When you need to kick off async work from a synchronous context, use Task.

Task is a lightweight object that wraps an async closure and schedules it on the current actor (usually the main thread). It doesn't block—it spawns background work and returns immediately:

swift
class UserViewModel: NSObject {
    @Published var user: User?
    @Published var isLoading = false
    
    func fetchUser(id: Int) {
        isLoading = true
        
        Task {
            defer { 
                DispatchQueue.main.async {
                    self.isLoading = false
                }
            }
            
            do {
                let fetchedUser = try await loadUser(id: id)
                DispatchQueue.main.async {
                    self.user = fetchedUser
                }
            } catch {
                print("Failed to load user: \(error)")
            }
        }
    }
}

Here, fetchUser(id:) is synchronous. It calls Task to spawn an async block. Inside the block, we can use await. When updating UI state (self.user), we dispatch back to the main thread to ensure thread safety.

A cleaner approach uses MainActor annotation:

swift
@MainActor
class UserViewModel: NSObject {
    @Published var user: User?
    @Published var isLoading = false
    
    func fetchUser(id: Int) {
        isLoading = true
        
        Task {
            defer { isLoading = false }
            
            do {
                self.user = try await loadUser(id: id)
            } catch {
                print("Failed to load user: \(error)")
            }
        }
    }
}

By marking the view model with @MainActor, Swift ensures all its properties and methods run on the main thread. No manual DispatchQueue.main.async needed. The compiler enforces this at build time, preventing subtle threading bugs.

You can also use Task.detached to spawn work on a background thread, useful for heavy computation:

swift
Task.detached(priority: .userInitiated) {
    let result = try await performExpensiveCalculation()
    DispatchQueue.main.async {
        self.result = result
    }
}

Parallel work — async let runs tasks concurrently

When you have multiple independent async operations, running them sequentially wastes time. Imagine fetching a user and their profile picture. With sequential awaits, you wait for the user, then wait for the picture. With async let, both happen at once:

swift
func loadUserWithProfilePicture(id: Int) async throws -> (User, Data) {
    async let user = loadUser(id: id)
    async let pictureData = loadProfilePicture(userId: id)
    
    return (try await user, try await pictureData)
}

The async let keyword binds the result of an async operation to a variable without waiting for it immediately. Both loadUser and loadProfilePicture start right away in parallel. Only when you await on both variables do you wait for their results.

This pattern is more efficient than wrapping both in a Task group or running them sequentially. The compiler understands your intent: "start these two operations concurrently, then give me both results."

For scenarios with more complex parallel work, use TaskGroup:

swift
func loadAllUserData(ids: [Int]) async throws -> [User] {
    var users: [User] = []
    
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                return try await loadUser(id: id)
            }
        }
        
        for try await user in group {
            users.append(user)
        }
    }
    
    return users
}

A task group lets you spawn multiple async tasks dynamically. Each task runs concurrently. You iterate over results as they complete. This is essential for batch operations where you don't know the count upfront or want finer control over concurrency limits.

Common mistakes

Forgetting await: Calling an async function without await gives you a closure, not the result. The compiler will warn you, but easy to miss in a code review.

swift
// Wrong: user is a function, not a User
let user = loadUser(id: 1)

// Correct: user is a User
let user = try await loadUser(id: 1)

Blocking the main thread: If you use DispatchQueue.main.sync or synchronous API calls inside an async function, you defeat the purpose. Use async APIs and await.

Ignoring cancellation: Tasks can be cancelled if a view disappears or a user navigates away. Check Task.isCancelled in long-running work, or structure your code so cancellation happens naturally.

Over-using sequential await: Don't chain unrelated async calls with await if they can run in parallel. Use async let or task groups.

Nesting Task without MainActor: If your ViewModel doesn't use @MainActor, manually dispatching to the main thread for state updates is easy to forget and leads to race conditions.

Key takeaways

  • Swift async await explained in Swift, SwiftUI & iOS Apps replaces callback-based concurrency with readable, suspension-based code.
  • Mark functions with async to indicate they can suspend. Use await inside async functions to pause at suspension points.
  • Call async functions from SwiftUI views with .task modifier or from synchronous code with Task.
  • Use async let to run independent operations concurrently, or TaskGroup for dynamic batches.
  • Always consider whether operations should run sequentially or in parallel. Parallel is faster when tasks are independent.
  • Use @MainActor on ViewModels to eliminate manual main-thread dispatching and prevent race conditions.
  • Async/await is thread-safe by design: the compiler catches many concurrency bugs at build time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles