The Versatility of Tasks

If you’ve been developing for Apple platforms for a while, your understanding of “concurrency” may have its origins in the era of Grand Central Dispatch. Or, if like me, you’ve been here a bit longer, maybe even pthreads. 🙈

Threads are mostly gone from modern Swift, but you can still use GCD through DispatchQueue. Say you have some processing to do, heavyWork. You could use a global queue to offload that work into the background:

DispatchQueue.global().async {
  heavyWork()
}

The modern approach, however, is to use a concurrent task:

Task { @concurrent in
  heavyWork()
}

These two don’t look too different, do they? Both take a closure and run it in the background until it’s finished. If you think about tasks this way, that’s fair — it’s a valid simplification. I certainly thought of them that way until not too long ago. But tasks are much more than asynchronously executed closures.

Beyond the closure

When you assign the result of creating a task to a variable, you get a handle to the task. Through this handle, you can cancel the task or await its completion (see this prior post).

We can never actually get the task itself — only a handle to it. For simplicity, the handle type has been named Task, but a task and its handle are two different things. If you drop the handle, the task keeps running. You just lose access to and control over it (see also this post).

Tasks also have a result value and an error type. In the example above, they’re unused. But we can extend the task to return the result of a computation — something that wouldn’t be possible with the dispatch queue call:

let workTask = Task { @concurrent in
  return heavyWork()
}

The variable workTask now holds that handle. It’s the same task type from prior posts where we awaited the result value or discussed cancellation. But this time, let’s think about what workTask actually is.

On the surface, the handle is just an ordinary variable. We can pass it around, store it in a dictionary, tell our friends about it, and so forth. We also know its type: Task<Value, Never>. But what is it conceptually? If you and I met and I handed you a Task<Value, Never> — what would that be for you?

You wouldn’t know anything about what’s going on inside the task. You wouldn’t know the closure. You wouldn’t know if there’s heavy or just light work happening. You wouldn’t know when the task was created or whether it had even started running. This is multithreading — it might have finished already, but you wouldn’t know.

All you’d really have is a promise. A promise that the task will finish eventually, that there will be a value. And you could wait for that promise to be fulfilled:

let result = await workTask.value

In some contexts and languages, this concept is actually called a promise. But the more common term, especially in the Apple/Swift ecosystem, is future — as in “something that will come to exist at some point in the future”.

Task handles are also sendable, making them safe to pass across threads. Every access to a task’s properties is atomic and thread-safe. This means every task can serve as a synchronization mechanism. You could fire off a single task for heavy work you want to perform only once, and multiple places could wait for that task’s value — regardless of what context or thread they’re on. No extra protections needed. Just wait on the value. Really powerful.

There’s another feature that might not be immediately obvious: tasks provide an asynchronous context. What does that mean? It means you can call async functions and await their completion. Is that special? Yes! For example, almost all callbacks in SwiftUI are synchronous, as are those from AppKit and UIKit. Synchronous is simple and deterministic, making it the right default. But certain operations take time and are better modeled as asynchronous functions (like network requests). Task bridges that gap.

This was one of those moments when it clicked for me: Swift’s Task is not just a closure running in the background. It’s also a future, a synchronization mechanism, and an async context. 🤯

In short

Tasks are versatile and adapt to many use cases:

  • In their simplest form, they’re closures that send long-running work to the background.
  • They provide an asynchronous context, enabling you to call async code.
  • If you keep the handle, you can await completion elsewhere or cancel the task when you no longer need it.
  • If you pass a task handle around, it serves as a future (or promise) for the result value.
  • Because they’re thread-safe, tasks also serve as synchronization mechanisms around their completion and result.

Depending on what you need, you can use some of these aspects or all at once — it’s always the same type with just four letters: Task. Of course, there are caveats to creating and using tasks, but those are for another post.

Update 27.01.26: Thanks to Friedrich for pointing out that, starting with Swift 6.2, using @concurrent is a better than .detached to attain a task that reliably executes in the background. Detached tasks have the downside of discarding @TaskLocal state, which might be unexpected and is rarely needed.