async/await in SwiftUI

Convert a SwiftUI app to use the new Swift concurrency and find out what’s going on beneath the shiny surface. By Audrey Tam.

4.6 (16) · 1 Review

Download materials
Save for later

Swift 5.5 has a shiny new structured concurrency framework to help you write safe code more swiftly. To help everyone get started, Apple provided a bunch of videos and sample code at WWDC 2021. There’s a quick run-down on what they cover at the end of this tutorial.

The Twitterverse exploded and the usual actors (;]) have already published several how-tos. This tutorial is like a micro version of Swift concurrency: Update a sample app from WWDC. You’ll take baby steps to convert a much simpler app to learn how async/await and actors help you write safer code. To help you decipher Xcode’s error messages and future-proof you against the inevitable future API changes, you’ll explore what’s going on beneath the shiny surface.

Spoiler: It feels like candy-coated GCD, so knowing GCD can help. Refresh your knowledge with our course iOS Concurrency with GCD and Operations or book Concurrency by Tutorials.
Note: You’ll need Xcode 13. This tutorial was written using beta 1. If you want to run this on an iOS device, it must be running iOS 15 beta. For your Mac, Big Sur is OK. If you have a Mac [partition] running the Monterey beta, you could try running your code there in case it’s not working on Big Sur. You should be comfortable with using SwiftUI, Swift and Xcode to develop iOS apps.

Getting Started

Create a new Xcode project that uses SwiftUI interface and name it WaitForIt.

Create a new project named WaitForIt.

Create a new project named WaitForIt.

In ContentView.swift, replace the body contents with this code:

AsyncImage(url: URL(string: "")) { image in
} placeholder: {
.frame(width: 128, height: 128)

In Xcode 13 beta 1, you get this error:

Availability error

Availability error

Don’t click any of the Fix buttons! Go to the target page and change Deployment Info from iOS 14.0 to iOS 15.0:

Set Deployment Info to iOS 15.0.

Set Deployment Info to iOS 15.0.

Go back to ContentView.swift. If the error message is still there, press Command-B to build the project.

Run Live Preview to see the image for the “SwiftUI vs. UIKit” video:



OK, that was just a quick check to fix that Xcode glitch and also to show you SwiftUI’s new AsyncImage view. Good, isn’t it? :]

Before you get to work on the real WaitForIt app, take a high level look at how the new Swift concurrency fixes problems with the old GCD concurrency.

Old and New Concurrency

The old GCD concurrency has several problems that make it hard to write apps that safely use concurrency.

Swift concurrency provides the necessary tools to carve work up into smaller tasks that can run concurrently. This lets tasks wait for each other to complete and allows you to effectively manage the overall progress of a task.

Pyramid of Doom

Swift APIs like URLSession are asynchronous. Methods automatically dispatch to a background queue and immediately return control to the calling code. Methods take a completion handler and call delegate methods. Completion or delegate code that accesses UI elements must be dispatched to the main queue.

If a completion handler calls another asynchronous function, and this function has a completion handler, it’s hard to see the happy path in the resulting pyramid of doom. This makes it hard to check the code is correct. For example, this sample code from WWDC’s Meet async/await in Swift downloads data, creates an image from the data, then renders a thumbnail from the image. Error handling is ad hoc because completion handlers can’t throw errors.

func fetchThumbnail(
  for id: String,
  completion: @escaping (UIImage?, Error?) -> Void
) {
  let request = thumbnailURLRequest(for: id)
  let task = URLSession.shared
    .dataTask(with: request) { data, response, error in
    if let error = error {
      completion(nil, error)
    } else if (response as? HTTPURLResponse)?.statusCode != 200 {
      completion(nil, FetchError.badID)
    } else {
      guard let image = UIImage(data: data!) else {
        completion(nil, FetchError.badImage)
      image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
        guard let thumbnail = thumbnail else {
          completion(nil, FetchError.badImage)
        completion(thumbnail, nil)

The sequence of operations is much easier to see with async/await, and you can take advantage of Swift’s robust error handling mechanism:

func fetchThumbnail(for id: String) async throws -> UIImage {
  let request = thumbnailURLRequest(for: id)
  let (data, response) = try await request)
  guard (response as? HTTPURLResponse)?.statusCode == 200 else {
    throw FetchError.badID
  let maybeImage = UIImage(data: data)
  guard let thumbnail = await maybeImage?.thumbnail else {
    throw FetchError.badImage
  return thumbnail

Data Races

When multiple tasks can read or write an object’s data, data races are possible. A data race occurs when one task sleeps while another task writes and exits, then the sleeping task resumes and overwrites what the previous task wrote. This creates inconsistent results.

In an app using the old concurrency, Xcode can detect data races if you enable the runtime Thread Sanitizer diagnostic in your app’s Run scheme. Then you can implement a serial queue to prevent concurrent access.

The new Swift concurrency model provides the Actor protocol to prevent concurrent access to an object’s data. Actors also enable you to structure your app into code that runs on the main thread and code that runs on background threads, so the compiler can help you prevent concurrent access.

Thread Explosion / Starvation

In GCD, the main unit of work is a thread. If your code queues up a lot of read/write tasks on a serial queue, most of them must sleep while they wait. This means their threads are blocked, so the system creates more threads for the next tasks. If each task also queues a completion handler on another queue, that creates even more threads. Every blocked thread holds onto a stack and kernel data structures so it can resume. A blocked thread may be holding resources that another thread needs, so that thread blocks.

This is a thread explosion: The system is overcommitted with many times more threads than it has cores to process them. The scheduler must allocate time to hundreds of threads, resulting in a lot of context switching. All of this slows down your app and can even starve some threads, so they never make any progress.

Tasks and Continuations

In Swift concurrency, the main unit of work is a task. A task executes jobs sequentially. To achieve concurrency, a task can create child tasks. Or you can create tasks in a task group.

The system knows these tasks are related so it can manage deadlines, priority and cancellation flags for all tasks in the task tree or group. This makes it easier for you to check and react to cancellation status, thus avoiding task leaks. If it’s important to react immediately to cancellation, you can write a function with a cancellation handler.

If a task suspends, it releases its thread and stores its state in a continuation. Threads switch between continuations instead of context switching.

Threads switch between continuations.

Threads switch between continuations.

Note: This image is from the WWDC session Swift concurrency: Behind the scenes

The keyword await marks a suspension point, and an async frame on the heap stores information that it needs when it resumes.

Ideally, the number of threads never exceeds the number of cores. There is a cooperative thread pool and a runtime contract that every thread will make progress. Your code maintains this contract by using await, actors and task groups to make dependencies visible to the compiler.


Enough theory! Time to convert a simple download to use async/await.

The starter folder contains JokeService.swift. Add this file to WaitForIt.

JokeService is an ObservableObject that sends a request to an API that returns a random Chuck Norris joke. I’ve adapted this code from a sample app in Combine: Asynchronous Programming with Swift. The query item specifies the dev category, so all the jokes have a techie flavor. Warning: Some of these jokes are a little violent.

JokeService publishes a joke and its isFetching status. Its fetchJoke() method uses the standard URLSession.shared.dataTask with completion handler. If anything goes wrong, it prints an error message with either the dataTask error or “Unknown error”. If the latter, it provides no information on whether the problem was in the data or in the decoder.

Minimal Error Handling

Robust error handling is one of the main reasons for async/await. The data task completion handler can’t throw errors so, if it calls a throwing function like JSONDecoder().decode(_:from:), it has to handle any thrown errors.

It’s common to take the easy way out and just ignore the error. That’s what the starter file does:

if let decodedResponse = try? JSONDecoder().decode(Joke.self, from: data)

Previous Xcode versions suggest this as a fix if you write just try and don’t enclose it in a do/catch. It means: Simply assign nil if the function throws an error.

Delete ? to see what happens:

Translation: You can't throw from here!

Translation: You can’t throw from here!

Xcode now takes a harder line: No more helpful suggestions of easy fixes.

But ? still works here, so put it back.

Show Me a Joke!

To fetch a joke, open ContentView.swift and replace the contents of ContentView with this:

@StateObject var jokeService = JokeService()

var body: some View {
  ZStack {
    VStack {
      Button { jokeService.fetchJoke() } label: {
        Text("Fetch a joke")
          .opacity(jokeService.isFetching ? 0 : 1)
          .overlay {
            if jokeService.isFetching { ProgressView() }

Run Live Preview and tap the button. It has a nice effect with opacity and ProgressView() to indicate a fetch is in progress.

A Chuck Norris joke

A Chuck Norris joke

Concurrent Binding

OK, the old way works, so now you’ll convert it to the new way.

Comment out URLSession down to and including .resume().

Add this code below isFetching = true:

async let (data, response) = url)

The new URLSession method data(from:) is asynchronous, so you use async let to assign its return value to the tuple (data, response). These are the same data and response that dataTask(with:) provides to its completion handler, but data(from:) returns them directly to the calling function.

Where’s the error that dataTask(with:) provides? You’ll find out soon — wait for it! ;]

These errors and suggested fixes appear:

You can't call async from a non-async function.

You can’t call async from a non-async function.

The errors are similar: You can’t call an asynchronous function in a synchronous function. You have to tell the compiler fetchJoke() is asynchronous.

Both fixes are the same, so click either one. This gives you:

func fetchJoke() async {

Like throws, the async keyword appears between the closing parenthesis and the opening brace. You’ll soon catch up with throws again.

Back to async let: This is one way to assign the result of data(from:) to the (data, response) tuple. It’s called a concurrent binding because the parent task continues execution after creating a child task to run data(from:) on another thread. The child task inherits its parent task’s priority and local values. When the parent task needs to use data or response, it suspends itself (releases its thread) until the child task completes.

The parent and child tasks run concurrently.

The parent and child tasks run concurrently.

Awaiting async

The verb for async is await in the same way the verb for throws is try. You try a throwing function and you await an async function.

Add this line of code:

await (data, response)
data(from:) throws.

data(from:) throws.

And there’s the missing error that dataTask(with:) provides to its completion handler: data(from:) throws it. So you must try await:

try! await (data, response)
Note: The keywords must be in this order, not await try.

You’re not really going to use this code, so you don’t bother to catch any thrown errors. This is just a chance to see what happens.

What happens is surprising:

Immutable value may only be initialized once.

Immutable value may only be initialized once.

It’s surprising because the Explore structured concurrency in Swift video says “And don’t worry. Reading the value of result again will not recompute its value.”

Note: This seems to be a tuple bug. You can await data or response, but not both.

Go ahead and accept the suggested fix to change let to var:

async can only be used with let declarations.

async can only be used with let declarations.

Hmph! Flashback to the early days of learning how to use Swift optionals with Xcode constantly saying “You can’t do that here”. Maybe it’s a beta bug. It doesn’t matter in this case because there’s no other code to execute between calling data(from:) and processing what it returns.

Sequential Binding

Instead, you’ll use the other binding: sequential binding.

Replace the two lines with this:

let (data, response) = try? await url)

Unlike async let, calling data(from:) this way doesn’t create a child task. It runs sequentially as a job in the fetchJoke() task. While it’s waiting for the server response, this job suspends itself, releasing the task’s thread.

The task's data(from:) job suspends.

The task’s data(from:) job suspends.

But there’s a problem:

Xcode refuses to understand try? here.

Xcode refuses to understand try? here.

You try the lazy way, but Xcode won’t have it this time, not even if you use nil coalescing to specify a nil tuple:

let (data, response) = 
  try? await url) ?? (nil, nil)

Nope, you’ll have to do the right thing. First, delete the ? and ?? (nil, nil):

let (data, response) = try await url)

Error Handling Options

You have two options for handling errors thrown by data(from:). The first is to bite the bullet and handle it right away with do/catch:

do {
  let (data, response) = try await url)
} catch {

The easier(?) option is to make fetchJoke() throw:

func fetchJoke() async throws {

These keywords must appear in this order — throws async doesn’t work:

async must precede throws.

async must precede throws.

Now fetchJoke() just passes the error up to whatever calls fetchJoke(). That’s the button in ContentView, where Xcode is already complaining about fetchJoke() being asynchronous:

fetchJoke() is async and throws: Do something!

fetchJoke() is async and throws: Do something!

Now what do you do? You can’t mark anything in ContentView as async.

Creating an Unstructured Task

Fortunately, you can create an asynchronous task in the button action. Replace Button { jokeService.fetchJoke() } label: { with this:

Button {
  async {
    try? await jokeService.fetchJoke()
} label: {

You create an asynchronous task with async { }. Because it’s asynchronous, you have to await its completion. Because it throws, you have to try to catch any errors. Xcode lets you use try? here, or you can write a do/catch statement.

Note: The task creation syntax will change to Task { ... } in a future beta.

This is an unstructured task because it’s not part of a task tree. The async let task you created in fetchJokes() is a child task of the task that’s running fetchJokes(). A child task is bound to the scope of its parent task: The fetchJokes() task cannot finish until its child tasks have finished.

An unstructured task inherits the actor, priority and local values of its origin, but isn’t bound by its scope. Cancelling its originating task doesn’t signal the unstructured task, and the originating task can finish even if the unstructured task has not finished.

Creating an unstructured task in a non-asynchronous context feels just like with a little less typing. But there’s a big difference: It runs on the MainActor thread with userInteractive priority, when the main thread won’t be blocked.

You can specify a lower priority with asyncDetached:

Specify a priority for a detached task.

Specify a priority for a detached task.

But it will still run on the main thread. More about this later.

Decoding the Joke

Back to JokeService.swift, to finish writing fetchJoke(). If you thought making it throw was the easier option, see what you think after this section.

Because fetchJoke() throws, it passes any error thrown by data(from:) to the calling function. You might as well take advantage of this mechanism and throw other errors that can happen.

Errors thrown by a throwing function must conform to the Error protocol, so add this code above the JokeService extension:

enum DownloadError: Error {
  case statusNotOk
  case decoderError

You create an enumeration for possible errors fetchJoke() can throw.

Then add this code to fetchJoke():

  let httpResponse = response as? HTTPURLResponse,
  httpResponse.statusCode == 200   // 1
else {
  throw DownloadError.statusNotOk
guard let decodedResponse = try? JSONDecoder()
  .decode(Joke.self, from: data) // 2
else { throw DownloadError.decoderError }
joke = decodedResponse.value   // 3

Using guard lets you throw your specific errors to the calling function.

  1. You check the response status code and throw statusNotOk if it isn’t 200.
  2. You decode the response and throw decoderError if something goes wrong.
  3. You assign the decoded value to joke.
Note: You always have the option of catching an error, including those thrown by data(from:), instead of throwing it.

Now, where to set isFetching to false? This Published value controls the button’s ProgressView, so you want to set it even if fetchJoke() throws an error. Throwing an error exits fetchJokes(), so you still need to set isFetching in a defer statement, before any possible early exit.

Add this line right below isFetching = true:

defer { isFetching = false }


If Xcode has trained you well, you might be feeling a little uneasy. Published values update SwiftUI views, so you can’t set Published values from a background thread. To set the Published values isFetching and joke, the dataTask(with:) completion handler dispatched to the main queue. But your new code doesn’t bother to do this. Will you get purple main thread errors when you run the app?

Try it. Build and run in a simulator. Nope, no main thread errors. Why not?

Because you used async { } to create the fetchJoke() task in the button action, it’s already running on the MainActor thread, with UI priority.

Actor is the Swift concurrency mechanism for making an object thread-safe. Like Class, it’s a named reference type. Its synchronization mechanism isolates its shared mutable state and guarantees no concurrent access to this state.

MainActor is a special Actor that represents the main thread. You can think of it as using only DispatchQueue.main. SwiftUI views all run on the MainActor thread, and so does the unstructured task you created.

To see this, place a breakpoint anywhere in fetchJoke(). Build and run, then tap the button.

fetchJoke() is running on the main thread.

fetchJoke() is running on the main thread.

Yes, fetchJoke() is running on the main thread.

What if you lower the priority? In ContentView.swift, in the button action, change async { to this:

asyncDetached(priority: .default) {
Note: The syntax of this will change to Task.detached in a future beta.

Build and run again. Tap the button:

fetchJoke() is still running on the main thread.

fetchJoke() is still running on the main thread.

You lowered priority to default, but this doesn’t move the task to a background queue. The task still runs on the main thread!

Note: This seems to be a fluke. The Explore structured concurrency in Swift video says a detached task inherits nothing from its origin, so it shouldn’t inherit the MainActor thread. A future Xcode beta might enforce this.

Change the code back to async {.

To move the asynchronous work off the main thread, you need to create an actor that isn’t MainActor.


Actors enable you to structure your app into actors on background threads and actors on the main thread, just as you now create model, view and view model files. Code in an actor (lower case, not MainActor) runs on a background thread. So you just need to move the asynchronous part of fetchJoke() into a separate actor.

In JokeService.swift, delete the breakpoint and add this code above JokeService:

private actor JokeServiceStore {
  private var loadedJoke = Joke(value: "")
  func load() async throws -> Joke {

You create an actor with a Joke variable and initialize it with an empty String, then write the stub of load(), where you’ll move the download code. This method resets loadedJoke and also returns the Joke, so you don’t really need a Joke property for this simple example, but you probably will for more complex data.

Next, create a JokeServiceStore object in JokeService (in the class, not the extension):

private let store = JokeServiceStore()

Now move the url code from JokeService into JokeServiceStore:

private var url: URL {

private var urlComponents: URLComponents {
  var components = URLComponents()
  components.scheme = "https" = ""
  components.path = "/jokes/random"
  components.setQueryItems(with: ["category": "dev"])
  return components

Then move the download code from fetchJoke() to load(), leaving only the two isFetching lines in fetchJoke():

// move this code from fetchJoke() to load()
let (data, response) = try await url)
  let httpResponse = response as? HTTPURLResponse,
  httpResponse.statusCode == 200
else {
  throw DownloadError.statusNotOk
guard let decodedResponse = try? JSONDecoder().decode(Joke.self, from: data)
else { throw DownloadError.decoderError }
joke = decodedResponse.value

JokeServiceStore has a Joke property, not a String property, so replace the last line with this code:

loadedJoke = decodedResponse
return loadedJoke

Instead of extracting just the value from decodedResponse, you set the Joke property and also return this Joke instance.

Now call load() in fetchJoke():

let loadedJoke = try await store.load()
joke = loadedJoke.value

Build and run. Tap the button.

A joke appears, but you have purple warnings:

Publishing changes from background threads is not allowed.

Publishing changes from background threads is not allowed.

Add breakpoints inside load() and in fetchJoke() at isFetching = true, let loadedJoke = ... and joke = loadedJoke.value:

Set breakpoints.

Set breakpoints.

Tap the button again, then watch the threads while you click Continue program execution after each breakpoint:

fetchJoke() starts on the main thread but moves to background thread.

fetchJoke() starts on the main thread but moves to background thread.

The first two lines of fetchJoke() run on the main thread because a view calls it. Then load() runs on a background thread, as it should. But when execution returns to fetchJoke(), it’s still on a background thread. You need to do something to make it run on the main thread.


Code that sets a Published value has to run on the MainActor thread. When fetchJoke() did all the work, and you called it from Button in an unstructured task, fetchJoke() inherited MainActor from Button, and all of its code ran on the MainActor thread.

Now fetchJoke() calls load(), which runs on a background thread. fetchJoke() still starts on the main thread but, when load() finishes, fetchJoke() continues running on a background thread.

fetchJoke() doesn’t have to rely on inheriting MainActor from Button. You can mark a class or a function with the @MainActor attribute to say that it must be executed on the MainActor thread.

Note: If you mark a class as @MainActor, any calls from outside MainActor must await, even when calling a method that completes its work immediately. A method that doesn’t reference any mutable state can opt out of MainActor with the keyword nonisolated.

Add this line above func fetchJoke() throws {


Build and run again and click through the breakpoints:

fetchJoke() runs on the main thread after load() finishes.

fetchJoke() runs on the main thread after load() finishes.

The first three breakpoints are the same as before, but now fetchJoke() runs on the main thread after load() finishes.

@MainActor fetchJoke() runs on main thread.

@MainActor fetchJoke() runs on main thread.

When fetchJoke() calls load(), it suspends itself, releasing the main thread to run UI jobs. When load finishes, fetchJoke() again runs on the main thread, where it’s allowed to set the Published values.

Your work here is done! Try converting your own SwiftUI projects: Take it slow, make small changes and try to keep the app buildable after each change.

One More Thing: Asynchronous View Modifiers

SwiftUI now has (at least) two view modifiers that expect their action to call an asynchronous function.

Create a new SwiftUI View file named RefreshableView.swift and replace the contents of RefreshableView with this:

@StateObject var jokeService = JokeService()

var body: some View {
  List {
    Text("Chuck Norris Joke")
  .task {
    try? await jokeService.fetchJoke()
  .refreshable {
    try? await jokeService.fetchJoke()
  1. This view is a List because refreshable(action:) only works with scrollable views.
  2. The task modifier performs its action when the view appears. Its action parameter’s type is @escaping () async -> Void. It creates a task to run the action so you don’t need to.
  3. The refreshable modifier’s action parameter type is the same. It must be asynchronous. When applied to a scrollable view, the user can pull down to refresh its content, and it displays a refresh indicator until the asynchronous task completes.

Run Live Preview. A joke appears:

A joke appears when the view loads.

A joke appears when the view loads.

Note: Actually, this algorithm is O(N). ;]

Pull down to fetch another joke. You might have to pull down quite far.

If you want to run this version in a simulator or on a device, open WaitForItApp.swift and change ContentView() to RefreshableView() in the WindowGroup closure.

Where to Go From Here?

Download the final project using the Download Materials button at the top or bottom of the tutorial.

In this tutorial, you converted a simple SwiftUI app from the old GCD concurrency to the new Swift concurrency, using async/await, throwing, an unstructured task, actor and @MainActor.

WWDC 2021 Videos

To learn more about the fundamental concepts of Swift concurrency, watch these videos first:

  • Meet async/await in Swift: This session introduces try await, get async, unstructured task and continuations.
  • Explore structured concurrency in Swift: This session covers concurrent and sequential binding, cancellation, task groups, unstructured and detached tasks. There’s a handy Flavors of task table near the end.
  • Protect mutable state with Swift actors: This session covers data races, actor, protocol conformance in extensions with nonisolated declarations, detached tasks, Sendable, conformance and MainActor.

These sessions are SwiftUI-specific:

  • Demystify SwiftUI: Get inspired to check all your SwiftUI code for inefficiencies. Lots of cute dog and cat photos.
  • Discover concurrency in SwiftUI: This session provides valuable tips for using structured concurrency in a SwiftUI app (SpacePhoto).

These talks cover a few specific purposes:

Set aside a good chunk of uninterrupted time for these deeper dives, or watch them in 10-15 minute chunks:

Melbourne Cocoaheads

And finally, two notable contributions from my colleagues at Melbourne Cocoaheads.

I hope you enjoyed this tutorial! Swift concurrency is a huge game-changer and it’s still evolving. There’s a lot to learn, so take some time now to explore it, even if you can’t really use it for a few years.

WWDC is like Christmas...

WWDC is like Christmas…

If you have any comments or questions, feel free to join in the forum discussion below!