Swift & Objective-C

[Advanced Swift #4] Structured Concurrency: Why You Shouldn't Open Tasks Carelessly

Structured concurrency answers who is responsible for ending asynchronous work. This article covers why child tasks cannot escape their parent scope, how cancellation propagates, and which guarantees you must manually reclaim with Task.detached.

6 min read
Cover image for [Advanced Swift #4] Structured Concurrency: Why You Shouldn't Open Tasks Carelessly

One final question remains to wrap up the Concurrency series. So far, we’ve covered async functions (Part 1), actor (Part 2), and Sendable (Part 3).

This continues from the previous article Advanced Swift #3.

We haven’t properly handled Task, the gateway into this asynchronous world. The moment we do, the most practical concept in this series appears.

Structured concurrency.

The name sounds grand, but the question is simple: after asynchronous work starts, who is responsible for its completion?

What “structured” means — work has scope too

The name comes from the old term structured programming. After the era when goto could send control flow anywhere, block structures such as if and for began guaranteeing that control returns to where it entered.

Structured concurrency applies the same principle to asynchronous work. Child tasks cannot escape their parent scope, and the parent cannot finish until every child has finished.

The async let we saw in Part 1 was actually the first example of this principle.

func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()    // child task 1
    async let feed = fetchFeed()          // child task 2
    return try await Dashboard(profile: profile, feed: feed)
}   // This function cannot return before both children are cleaned up

Children created with async let must complete or be cancelled before the function returns. The same applies when an error occurs.

If feed throws an error, profile, which is still running, automatically receives a cancellation signal. The function propagates the error after cleanup.

Because work is tied to a scope, a “start it and forget it” task cannot structurally exist.

In the closures installment, escaping was a warning label for a “closure that outlives the function.” Structured concurrency removes “work that outlives the function” from the default model altogether.

When the number of children is dynamic, use TaskGroup. A typical implementation for fetching a list of URLs in parallel looks like this.

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask { (url, try await download(url)) }
    }
    var result: [URL: Image] = [:]
    for try await (url, image) in group {
        result[url] = image
    }
    return result
}

Two points matter. Results arrive in completion order (if order matters, store them with keys as shown above), and every child in the group is cleaned up when the closure ends.

If async let is the syntax for “a few children,” TaskGroup is the syntax for “n children,” with the same scope guarantee.

Cancellation — the signal arrives, but stopping is your job

The second pillar of structured concurrency is cancellation propagation. When a parent is cancelled, a cancellation signal travels down the tree to every descendant.

This is why leaving a screen can trigger a chain of cancellations for the network requests it started.

But cancellation in Swift is cooperative. A cancellation signal does not forcibly kill a task.

It only sets the isCancelled flag; checking that flag and stopping is the task’s responsibility.

func processLargeFile() async throws {
    for chunk in chunks {
        try Task.checkCancellation()   // If cancelled, CancellationError is thrown
        await process(chunk)
    }
}

Why not force termination? If a task dies while half-writing a file, holding a lock, or midway through a transaction, it can leave the system corrupted.

The logic of cooperative cancellation is that “only the task itself knows where it is safe to stop.”

The practical implication is clear: add checkCancellation to long-running loops.

System APIs such as URLSession already respect cancellation internally, so you can rely on them.

Conversely, a long-running computation that ignores cancellation will keep running after cancellation. Cancellation isn’t broken; it simply wasn’t checked.

Diagram of cancellation signals traveling from parent to child and a checking checklist
The cancellation signal travels down the tree, but only tasks that check it stop

Task and Task.detached — the unstructured world and its cost

If everything so far has been the world of structure, Task { } is outside it. A task created with Task is unstructured work that does not belong to the parent-child tree.

It is not tied to a scope, and errors and cancellation do not propagate automatically.

Why does it exist, then? Because we need a bridge from the synchronous world to the asynchronous one.

A button-tap handler is synchronous and cannot use await, so Task { await viewModel.refresh() } opens a new asynchronous context. That is Task’s legitimate role.

SwiftUI’s .task modifier adds lifetime management to this bridge (automatic cancellation when the view disappears), so it should be preferred in UI code.

The problem begins when Task becomes a habit. Tasks opened inside async functions and fire-and-forget Tasks are examples. You must manually reclaim every guarantee provided by structure: waiting for completion, error propagation, and cancellation chaining.

You have to retain references, call cancel yourself, and log errors yourself. Without that management code, errors disappear silently and zombie tasks appear.

As a rule, use async let and TaskGroup by default inside async contexts, and Task only at the synchronous-to-asynchronous boundary.

Task.detached is one step farther outside. It inherits no priority, actor isolation, or task-local values; it is a completely orphaned task (SE-0304).

People often use it because they want to “leave the @MainActor context and do heavy work,” but that is usually the wrong prescription. Declare the function nonisolated async and it will run in the cooperative pool automatically (Part 1).

Cases that truly need detached are rare—roughly, background work that must be intentionally independent of the current context. In the wording of the official documentation, it is a last resort.

Series synthesis — one picture formed by four concepts

As we close the Concurrency series, let’s summarize the whole picture at once.

async/await brought asynchronous flow back into the compiler’s view (Part 1). actor built serial protection into shared mutable state (Part 2), and Sendable checks the safety of values crossing isolation boundaries (Part 3).

Structured concurrency ties task lifetimes to scopes. What starts must finish, and cancellation flows down the tree (this installment).

One sentence runs through all four: turn concurrency’s implicit discipline into explicit language structures.

Thread-management discipline became cooperative pools and suspension. Lock discipline became actor. Oral knowledge about whether an object can cross a thread became Sendable, and the review comment “don’t forget to clean up the request” became a task tree.

The safety-first principle from Part 1 of the Swift philosophy series has now expanded into concurrency, its most difficult domain. That is the overall story of Swift Concurrency.

Illustration contrasting structured tasks inside a fence with floating unstructured Tasks
Tasks outside the structure require completion, error, and cancellation guarantees to be reclaimed manually

Summary

  • Structured concurrency means that child tasks cannot escape their parent scope. async let (a fixed count) and TaskGroup (a dynamic count) provide the syntax, with automatic sibling cancellation and cleanup on error.
  • Cancellation is cooperative. The signal propagates down the tree, but the task that stops is the one that checks with checkCancellation.
  • Use Task { } only as a synchronous-to-asynchronous bridge. Habitual Task use inside async contexts turns structural guarantees into manual-management debt. Task.detached is a last resort.
  • The series in one line: implicit discipline around threads, locks, and lifetimes has moved into language structures called suspension, actor, Sendable, and the task tree.

Starting next installment, we go deep into performance: why final is a performance keyword, where protocol calls slow down, and what static and dynamic dispatch really are.

Continue reading

Sources and verification

  • SE-0304: Structured ConcurrencySwift Evolution · Standard or specification · Checked August 17, 2026Supports: Task and TaskGroup parent-child structure; cancellation, priority, task-local, and actor context inheritance