Skip to content

How to use async / await keywords in Swift | Modern Swift Concurrency #3

With GCD, the code that describes what you want to do is split across multiple nested closures and queue dispatches — following the actual execution order requires reading inside-out. async/await puts that execution order back into a linear sequence that reads top-to-bottom, while the Swift runtime handles thread management automatically.

What You'll Learn

  • How async marks a function as suspendable and await marks the exact points where suspension can occur
  • Why await does not block a thread — what "cooperative suspension" means and how it differs from locking or sleeping
  • How Thread.current in the sample output reveals that Swift moves your code between threads transparently across suspension points

Mental Model

Imagine you're a contractor with multiple job sites. With GCD, you physically drive to each site in sequence, wait on-site until each task is done, then drive to the next — one dispatcher queue, one closure at a time. With async/await, you're more like a project manager with a radio. You call a team, ask them to start a task, and tell them to radio you when they're done (await). While you wait, you manage other jobs — you're never idle. When the radio crackles with "done," you pick up right where you left off on that job site.

The Thread.current output in the sample makes this concrete: the same logical function may resume on a different thread after an await. This is fine and expected. What matters is that you're never on two threads at the same time for the same work, and you never block a thread while waiting.

Detailed Explanation

The async keyword in a function signature is a promise to the Swift compiler: "this function may suspend during execution." A suspended function yields the thread it was running on back to Swift's cooperative thread pool, which can immediately use that thread for other work. When the awaited operation completes, the runtime resumes the function — potentially on a different thread. From your code's perspective, this is invisible: execution continues on the next line as if nothing happened.

await is the call-site marker for potential suspension. Every await is a visible suspension point — the reader knows "something asynchronous is happening here." This is intentional: Swift's designers wanted suspension to be visible in the source code, unlike implicit suspension in some other async systems. You can reason about your function's flow by looking for await markers.

Comparing the GCD functions (addTitle1, addTitle2) to the async functions (addAuthor1, addSomething) reveals the difference clearly. In addTitle2, the code that runs on the background queue and the code that runs on the main queue are written in separate nested closures — you have to mentally track which closure runs where. In addAuthor1, the code before Task.sleep runs in the ambient context (main actor, since the view model isn't explicitly off-main), and the code after runs in MainActor.run — the structure is flat and the thread hops are explicit.

Task.sleep(nanoseconds:) is the async equivalent of Thread.sleep(). The critical difference: Thread.sleep() blocks the thread entirely — no other work can run on that thread. Task.sleep(nanoseconds:) suspends the task but frees the thread, so the cooperative thread pool can run other tasks during the sleep. This is why async/await scales better than GCD for concurrent workloads.

MainActor.run(body:) is the async/await equivalent of DispatchQueue.main.async { }. It schedules the closure to run on the main actor (main thread) and, because it is itself an async function, it can be awaited. This means you can write UI updates inline in the same function as your background work without nesting closures.

The commented-out addTitle1() and addTitle2() calls in the view demonstrate the old GCD approach. The live .onAppear code shows the same logical sequence using async/await: addAuthor1() runs, then addSomething() runs sequentially after it, then a final string is appended. Each await in the Task body makes the sequential ordering explicit.

Code Structure

AsyncAwaitBootcamp.swift contains AsyncAwaitBootcampViewModel with four functions: two using GCD (addTitle1, addTitle2) and two using async/await (addAuthor1, addSomething). The view builds a list from the growing dataArray and starts the async chain in .onAppear. The Thread.current prints in each function let you observe which thread is executing at each step.

Complete Code

AsyncAwaitBootcamp.swift

swift
import SwiftUI

class AsyncAwaitBootcampViewModel: ObservableObject {
    
    @Published var dataArray: [String] = []
    
    // GCD approach: synchronous function, schedules work on the main queue after 2 seconds
    func addTitle1() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.dataArray.append("Title1 : \(Thread.current)") // runs on main thread after 2s delay
        }
    }
    
    // GCD approach: hops to a background queue, builds the string, then hops back to main to update UI
    func addTitle2() {
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            let title = "Title2 : \(Thread.current)" // captures Thread.current on the background queue
            DispatchQueue.main.async {
                self.dataArray.append(title) // safe: now on main thread for UI update
                
                let title3 = "Title3 : \(Thread.current)" // Thread.current is now the main thread
                self.dataArray.append(title3)
            }
        }
    }
    
    // async/await approach: runs synchronously up to the first await, then suspends and frees the thread
    func addAuthor1() async {
        let author1 = "Author1 : \(Thread.current)" // captured before any suspension — same thread as the caller
        self.dataArray.append(author1)
        
        try? await Task.sleep(nanoseconds: 2_000_000_000) // suspends this task for 2 seconds; thread is freed for other work
        
        let author2 = "Author2 : \(Thread.current)" // may be a different thread after resuming from suspension
        await MainActor.run(body: {
            self.dataArray.append(author2) // explicitly hop to main actor before mutating @Published state
            
            let author3 = "Author3 : \(Thread.current)" // Thread.current here is the main thread
            self.dataArray.append(author3)
        })
    }
    
    func addSomething() async {
        
        try? await Task.sleep(nanoseconds: 2_000_000_000) // suspends for 2 seconds; addAuthor1 must finish before this runs
        let something1 = "Something1 : \(Thread.current)"
        await MainActor.run(body: {
            self.dataArray.append(something1) // hop to main for UI update
            
            let something2 = "Something2 : \(Thread.current)"
            self.dataArray.append(something2)
        })
        
    }
    
    
}

struct AsyncAwaitBootcamp: View {
    
    @StateObject private var viewModel = AsyncAwaitBootcampViewModel()
    
    var body: some View {
        List {
            ForEach(viewModel.dataArray, id: \.self) { data in
                Text(data)
            }
        }
        .onAppear {
            Task { // bridge from synchronous onAppear into async context
                await viewModel.addAuthor1()   // awaits completion: Author1, Author2, Author3 are appended, then this returns
                await viewModel.addSomething() // only starts after addAuthor1 fully completes
                
                let finalText = "FINAL TEXT : \(Thread.current)"
                viewModel.dataArray.append(finalText) // appended only after both async functions complete
            }
//            viewModel.addTitle1()
//            viewModel.addTitle2()
        }
    }
}

struct AsyncAwaitBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        AsyncAwaitBootcamp()
    }
}

Code Walkthrough

  1. GCD vs async — the structural differenceaddTitle2 uses two nested closures (DispatchQueue.global().asyncAfter containing DispatchQueue.main.async). You must read from the outside in to understand execution order. addAuthor1 is flat: read top-to-bottom and you understand the sequence. Both accomplish the same logical thing — background work then UI update — but the readability difference compounds as code grows.

  2. addAuthor1() — tracing suspension — Execution reaches try? await Task.sleep(...) and suspends. The thread is freed. Two seconds later, the runtime resumes addAuthor1 — likely on a different thread from the cooperative pool. The await MainActor.run then hops execution to the main thread for the @Published mutation. Run this and print Thread.current at each step: Author1 is typically on the main thread (because the parent Task inherits the @MainActor context of AsyncAwaitBootcamp), and Author2 may or may not be.

  3. Sequential awaits in Task { } — Inside the Task body, await viewModel.addAuthor1() followed by await viewModel.addSomething() execute sequentially. addSomething does not start until addAuthor1 returns. This is structurally equivalent to chaining callbacks, but without the nesting. The total elapsed time before FINAL TEXT is appended is 4 seconds (2s + 2s), not 2s, because the awaits are sequential, not concurrent. For concurrent execution, see lesson 5 on async let.

  4. Thread.current in the output — The printed thread descriptions reveal when Swift hops threads. Notice that code written after await MainActor.run { } may continue on the main thread or may resume on a pool thread depending on context. The important point: you don't control which thread resumes your code after a suspension point, and you don't need to — the actor system ensures correctness regardless.

  5. Task.sleep vs Thread.sleep — Both sleep for 2 seconds, but Task.sleep(nanoseconds:) is a cooperative suspension — it suspends the task and returns the thread to the pool. Thread.sleep is a blocking call that holds the thread hostage for the duration. In a UI app with a finite thread pool, using Thread.sleep or other blocking calls inside async functions can starve the pool and cause unresponsive UI.

Common Mistakes

Mistake: Calling a blocking API (Thread.sleep, semaphore.wait, synchronous file I/O) inside an async function An async function runs on Swift's cooperative thread pool, which has a limited number of threads. Blocking one thread inside an async context doesn't just affect your task — it removes a thread from the pool, potentially causing other async tasks to stall. Always use async equivalents: Task.sleep instead of Thread.sleep, URLSession.data(from:) instead of URLSession.dataTask.

Mistake: Assuming code after await runs on the same thread as code before await After a suspension point, Swift may resume your function on any available thread in the cooperative pool. Code that relies on thread-local storage or thread identity after an await will behave unpredictably. Actor isolation (not thread identity) is the correct tool for ensuring code runs in a specific context.

Mistake: Starting async work directly in a Task in body rather than in onAppear or .task Creating a Task directly inside a SwiftUI body property creates a new task every time the view re-evaluates — which can happen many times per second. Each evaluation launches an additional parallel download or operation. Always start async work in response to lifecycle events (.task, onAppear) or user actions, not in body.

Key Takeaways

  • Every await is a potential suspension point where the task yields its thread — the runtime may resume the task on a different thread, which is safe and expected
  • Task.sleep(nanoseconds:) suspends cooperatively (frees the thread); Thread.sleep blocks the thread — never use Thread.sleep inside async code
  • Sequential await calls in a Task body execute one-after-another and their total time is additive; for concurrent execution of independent work, use async let (lesson 5) or TaskGroup (lesson 6)

Last updated: June 27, 2026

Released under the MIT License.