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.
- async marks a function, method, closure, or computed property that can suspend.
- await marks a call that may pause until a result is ready.
- The current thread is not blocked while the task is suspended.
- The code remains structured, which makes it easier to read than callback-based code.
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:
- Write asynchronous code that follows the same top-to-bottom style as synchronous code.
- Reduce callback nesting and “pyramid of doom” structures.
- Handle errors with familiar Swift error handling patterns.
- Compose multiple async operations more safely.
- Make concurrency code easier to maintain and test.
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
- Fetching JSON from a web API without blocking the main thread.
- Loading files from disk in a command-line tool or app startup sequence.
- Waiting for timers, debounced input, or delayed actions.
- Running multiple independent requests at the same time.
- Building async helpers that wrap older callback-based APIs.
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
- await only works inside an async context unless you create one with Task or another concurrency entry point.
- async does not automatically make code faster; it helps with responsiveness and structure, not raw CPU performance.
- Suspension points can happen in the middle of a function, so shared mutable state still needs careful design.
- Not every API is async. Older APIs may still use completion handlers, and you may need to bridge them.
- Task.sleep and similar APIs throw when the task is cancelled, so you may need to handle cancellation explicitly.
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
- async marks code that can suspend.
- await waits for an async result from an async context.
- async throws is the common pattern for operations that can both suspend and fail.
- Task lets you start async work from synchronous code.
- async let is useful for independent concurrent work.
11. Practice Exercise
- Create an async function named fetchGreeting that returns a String.
- Make it pause briefly using Task.sleep.
- Call it from a Task and print the result.
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.