How to determine where tasks and async functions run in Swift?

Published on: February 16, 2024
Updated on: September 19, 2025

Note: This is an updated post for Swift 6.2. For a full overview on how Swfift 6.2 has changed where your code runs, take a look at this overview of Swift 6.2 features.

Swift’s current concurrency model leverages tasks to encapsulate the asynchronous work that you’d like to perform. I wrote about the different kinds of tasks we have in Swift in the past. You can take a look at that post here. In this post, I’d like to explore the rules that Swift applies when it determines where your tasks and functions run. More specifically, I’d like to explore how we can determine whether a task or function will run on the main actor or not.

We’ll start this post by very briefly looking at tasks and how we can determine where they run. I’ll dig right into the details so if you’re not entirely up to date on the basics of Swift’s unstructured and detached tasks, I highly recommend that you catch up here.

After that, we’ll look at asynchronous functions and how we can reason about where these functions run.

To follow along with this post, it’s recommended that you’re somewhat up to date on Swift’s actors and how they work. Take a look at my post on actors if you want to make sure you’ve got the most important concepts down.

Reasoning about where a Swift Task will run

In Swift, we have two kinds of tasks:

  • Unstructured tasks
  • Detached tasks

Each task type has its own rules regarding where the task will run its body.

When you create a detached task, this task will always run its body using the global executor. In practical terms this means that a detached task will always run on a background thread. You can create a detached task as follows:

Task.detached {
  // this runs on the global executor
}

A detached task should hardly ever be used in practice because there are other ways to perform work in the background that don’t involve starting a new task (that doesn’t participate in structured concurrency).

The other way to start a new task is by creating an unstructured task. This looks as follows:

Task {
  // this runs ... somewhere?
}

An unstructured task will inherit certain things from its context, like the current actor for example. It’s this current actor that determines where our unstructured task will run.

Sometimes it’s pretty obvious that we want a task to run on the main actor:

Task { @MainActor in 

}

While this task inherits an actor from the current context, we’re overriding this by annotating our task body with MainActor to make sure that our task’s body runs on the main actor.

Interesting sidenote: you can do the same with a detached task.

Additionally, we can create a new task that’s on the main actor like this:

@MainActor
struct MyView: View {
  // body etc...

  func startTask() {
    Task {
      // this task runs on the main actor
    }
  }
}

Our SwiftUI view in this example is annotated with @MainActor. This means that every function and property that’s defined on MyView will be executed on the main actor. Including our startTask function. The Task inherits the main actor from MyView so it’s running its body on the main actor.

If we make one small change to the view, everything changes:

struct MyView: View {
  // body etc...

  func startTask() {
    Task {
      // where does this task run?
    }
  }
}

Instead of knowing that startTask will run on the main actor, it's a bit trickier to reason about where our function will run exactly. Our view itself is not main actor bound which means that its functions can be called on any actor or executor. When we call startTask, we'll find that the Task that's created in its function body will not be main actor isolated. Not even if you call this function from a place that is main actor isolated. This seems to be related to startTask being nonisolated by definition which means that it's never bound to a specific actor and runs on the global executor which results in unstructured Tasks being spawned on the global excutor too.

At runtime, we can use MainActor.assertIsolated(_:) to perform a check and see whether we're on the main actor. If we're not, our app would crash during development which is perfectly fine. Especially when we're using this function as a tool to learn more about our code. Here's how you can use this function:

struct MyView: View {
  // body etc...

  func startTask() {
    Task {
      MainActor.assertIsolated("Not isolated!!")
    }
  }
}

When I ran this example on my device, it never crashed which shows that the runtime behavior is not something that's random. We can already know at compile time that our code will run on the main actor because that's the default setting for Xcode 26. Another reason would be that we call startTask from the view's body which means it's called from a spot that's main actor isolated (due to View being @MainActor annotated).

Let's take a look at how we can leverage async functions to make sure our code doesn't run on the main actor.

Reasoning about where an async function runs in Swift

Whenever you want to call an async function in Swift, you have to do this from a task and you have to do this from within an existing asynchronous context. If you’re not yet in an async function you’ll usually create this asynchronous context by making a new Task object.

From within that task you’ll call your async function and prefix the call with the await keyword. It’s a common misconception that when you await a function call the task you’re using the await from will be blocked until the function you’re waiting for is completed. If this were true, you’d always want to make sure your tasks run away from the main actor to make sure you’re not blocking the main actor while you’re waiting for something like a network call to complete.

Luckily, awaiting something does not block the current actor. Instead, it sets aside all work that’s ongoing so that the actor you were on is free to perform other work. I gave a talk where I went into detail on this. You can watch the talk here:

Knowing all of this, let’s talk about how we can determine where an async function will run. Examine the following code:

struct MyView: View {
  // body etc...

  nonisolated func performWork() async {
    // Can we determine where this function runs?
  }
}

The performWork function is marked async which means that we must call it from within an async context, and we have to await it.

A reasonable assumption would be to expect this function to run on the actor that we’ve called this function from.

For example, in the following situation you can expect performWork to run on the main actor:

struct MyView: View {
  var body: some View {
    Text("Sample...")
      .task {
        await peformWork()
      }
  }

  nonisolated func performWork() async {
    // Can we determine where this function runs?
  }
}

If you run this code, you'll see that performWork runs on the main actor. It's a nonisolated async function which means that this function will inherit the caller's actor if you're using Xcode 26's default settings. If we had written performWork as @concurrent func performWork() async, performWork would never run on the main actor.

You can learn more about @concurrent in Swift 6.2 in my post on @concurrent.

Let's look at another example:

struct MyView: View {
  var body: some View {
    Text("Sample...")
      .onAppear {
        Task.detached {
          await peformWork()
        }
      }
  }

  func performWork() async {
    // This function will run on the main actor
  }
}

Even though we're calling performWork from a detached Task, the function isn't explicitly nonisolated. This means that we have an implicit @MainActor annotation applied to it (due to Xcode 26's default main actor execution).

In Summary

While the rules are pretty clear, it's not always trivial to determine where an async function will run.

The key is always to look at the function itself first. If there’s no @concurrent or nonisolated applied to the function it might have an (implicit) @MainActor annotation depending on the project's settings. Check for the default actor isolation setting in Xcode 26 to make sure.

If the function is marked as @concurrent, you know that it'll always run on the global executor and never on the main actor.

If the function is nonisolated you can check whether nonisolated(nonsending) is enabled in the project. By default, new Xcode 26 projects will have this setting enabled. If this is the case, all nonisolated functions will inherit the caller's actor. So in that case, check where you're calling the function from to determine where it will run.

Expand your learning with my books

Practical Combine header image

Learn everything you need to know about Combine and how you can use it in your projects with Practical Combine. It contains:

  • Thirteen chapters worth of content.
  • Playgrounds and sample projects that use the code shown in the chapters.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more