Swift Async & Await: Writing and Reading Asynchronous Code

Swift's async and await keywords make asynchronous code easier to read, write, and reason about. Instead of nesting callbacks or chaining completion handlers, you can write code that looks more like normal sequential Swift while still letting work pause and resume without blocking the current thread.

Quick answer: Mark a function with async when it may suspend while waiting for work to finish, and use await when calling that function from another async context. Together, they let Swift handle suspension points safely and clearly.

Difficulty: Beginner

You'll understand this better if you know: basic Swift functions, error handling with do and catch, and the idea that a program can wait for slow work like networking or timers.

1. What Is Swift Async & Await?

async and await are language features for asynchronous programming in Swift. They let a function pause at specific suspension points and continue later when the awaited work finishes.

In practice, async/await is Swift's preferred way to express operations that take time, such as fetching data, reading files, or coordinating multiple independent tasks.

2. Why Swift Async & Await Matters

Before async/await, asynchronous Swift code often relied on escaping completion handlers, nested callbacks, or manual state management. That approach works, but it gets harder to read as the logic grows.

Async/await matters because it helps you:

It is especially useful for network requests, disk access, and other work that should not freeze the user interface.

3. Basic Syntax or Core Idea

A function that may suspend is declared with async. If it can throw, you add throws as well, making it async throws.

Minimal async function

The simplest form is a function that suspends and then returns a value.

func fetchUsername() async -> String {
    return "Taylor"
}

This function is declared with async, so callers must use await when they call it from an async context.

Calling an async function

Use await at the call site to wait for the result.

let name = await fetchUsername()
print(name)

The await keyword tells Swift that this line may suspend until the result is available.

Async throws

Many real async operations can fail, so Swift combines concurrency and error handling naturally.

func loadMessage() async throws -> String {
    throw URLError(.badServerResponse)
}

Callers use both try and await together when a function can fail and suspend.

4. Step-by-Step Examples

Example 1: Waiting for a simulated delay

This example uses Swift's built-in sleep-style suspension to show how an async function pauses without blocking the rest of the program.

func sayHelloAfterDelay() async {
    await Task.sleep(UInt64(1) * 1_000_000_000)
    print("Hello after a delay")
}

The Task.sleep call suspends for one second. During that time, the task is not busy-waiting.

Example 2: Fetching two values in sequence

When one result depends on another, you can await them one by one.

func loadProfile() async -> String {
    let username = await fetchUsername()
    return "Profile for \(username)"
}

This keeps the dependency clear: the profile text is built only after the username is available.

Example 3: Calling async code from a task

To start asynchronous work from synchronous code, wrap it in a Task.

func startLoading() {
    Task {
        let message = await loadProfile()
        print(message)
    }
}

The task creates an asynchronous context, which allows await inside the closure.

Example 4: Concurrently waiting for independent work

If two operations do not depend on each other, you can start them together and then await both results.

func loadDashboard() async -> (String, String) {
    async let headline = fetchUsername()
    async let subtitle = loadMessage()

    return (await headline, await subtitle)
}

This pattern is useful when tasks are independent and you want them to overlap in time.

5. Practical Use Cases

Async/await is most valuable anywhere you would otherwise need to juggle completion handlers, callback queues, or manual state machines.

6. Common Mistakes

Mistake 1: Calling an async function from synchronous code without a task

New Swift developers often try to call an async function as if it were a normal function. That does not work because there is no asynchronous context to suspend in.

Problem: Swift reports an error such as 'async' call in a function that does not support concurrency or Expression is 'async' but is not marked with 'await'.

func loadData() {
    let name = fetchUsername()
    print(name)
}

Fix: Create an async context with Task or make the caller async.

func loadData() {
    Task {
        let name = await fetchUsername()
        print(name)
    }
}

The corrected version works because the task provides an async context where suspension is allowed.

Mistake 2: Forgetting to mark the function as async

If a function uses await, it must be declared with async. Swift enforces this so suspension points are explicit.

Problem: The compiler rejects the call because the function signature does not allow suspension.

func showName() -> String {
    let name = await fetchUsername()
    return name
}

Fix: Add async to the signature, then call it with await from an async context.

func showName() async -> String {
    let name = await fetchUsername()
    return name
}

The fixed version works because the compiler now knows the function can suspend.

Mistake 3: Using await when the function is not async

Not every function needs await. If a function returns immediately, adding await is incorrect and confusing.

Problem: Swift reports that the function is not asynchronous, so there is nothing to await.

func makeGreeting() -> String {
    return "Hello"
}

let greeting = await makeGreeting()

Fix: Remove await unless the function is actually async.

let greeting = makeGreeting()

The corrected version works because synchronous code should be called directly without an unnecessary suspension point.

7. Best Practices

1. Mark only the functions that really suspend

Do not add async everywhere by default. Keep synchronous functions synchronous so the API stays honest and easier to use.

func fullName(first: String, last: String) -> String {
    return "\(first) \(last)"
}

This stays simple because the function never needs to suspend.

2. Prefer structured concurrency when tasks are related

If one piece of work depends on another, keep them in the same async flow instead of spawning unrelated detached work.

func loadAndFormatProfile() async -> String {
    let username = await fetchUsername()
    return "User: \(username)"
}

Keeping the work structured makes cancellation, errors, and control flow easier to manage.

3. Await independent work in parallel when possible

When two async operations do not depend on one another, start them together with async let instead of waiting serially.

func loadHeader() async -> String {
    async let a = fetchUsername()
    async let b = loadMessage()
    return await a + " - " + await b
}

This can reduce total waiting time because the operations overlap.

8. Limitations and Edge Cases

Warning: Async code does not remove data-race risk by itself. You still need to respect Swift's concurrency rules when sharing mutable state across tasks.

9. Practical Mini Project

Let's build a tiny command-line style example that loads two independent values and prints a combined message. This shows how async functions, await, and async let work together in a complete flow.

import Foundation

func fetchUsername() async -> String {
    await Task.sleep(UInt64(1) * 1_000_000_000)
    return "Taylor"
}

func fetchStatus() async -> String {
    await Task.sleep(UInt64(1) * 1_000_000_000)
    return "online"
}

func buildSummary() async -> String {
    async let name = fetchUsername()
    async let status = fetchStatus()

    return "\(await name) is \(await status)"
}

Task {
    let summary = await buildSummary()
    print(summary)
}

This example runs two independent operations in parallel, then combines their results into one final string. In a real app, those operations might represent two network calls or a mix of file and network work.

10. Key Points

11. Practice Exercise

Expected output: A greeting string printed after a short delay.

Hint: Remember that Task.sleep takes a time value in nanoseconds, and the call itself must be awaited.

Solution:

import Foundation

func fetchGreeting() async -> String {
    await Task.sleep(UInt64(500) * 1_000_000)
    return "Hello, async world!"
}

Task {
    let greeting = await fetchGreeting()
    print(greeting)
}

12. Final Summary

Swift async/await gives you a clear, modern way to write asynchronous code without callback nesting. By marking suspension-capable functions with async and using await at call sites, you describe timing and dependencies directly in the code.

Use Task when you need to begin async work from synchronous code, and use async let when independent operations can run in parallel. As you build larger programs, these tools help you keep concurrency code readable, testable, and much easier to maintain.

If you want to continue, the best next topic is Swift structured concurrency, especially Task, task groups, and cancellation.