top of page

01 · INTRODUCTION

What Is an Unstructured Task in Swift?

The short answer

An unstructured task is a Swift task whose lifetime is not bounded by the function or lexical scope that creates it. You create one with Task { }, receive a task handle and become responsible for deciding who owns its result, cancellation and lifetime.

CODE EXAMPLE

let refreshTask = Task {
    try await launchManager.fetchLaunches()
}

The task begins executing independently of the surrounding function’s return. The task handle can be used to await its value or request cancellation, but the creating scope does not automatically do either.

Important

Task { } creates an unstructured task. It does not create a structured child task, a new thread or automatic background execution.

02 · TUTORIAL

Why Unstructured Tasks Exist

Structured concurrency is the preferred model when concurrent work belongs to one asynchronous operation. An async let child or task-group child must finish before its scope exits.

CODE EXAMPLE

func loadLaunchPage() async throws -> LaunchPage {
    async let launch = launchAPI.fetchNextLaunch()
    async let rocket = rocketAPI.fetchRocketDetails()

    return try await LaunchPage(
        launch: launch,
        rocket: rocket
    )
}

Some work has a different owner. A synchronous button action may need to begin an asynchronous refresh. A feature may need to keep that refresh alive after the button method returns and cancel it when another refresh begins.

CODE EXAMPLE

@MainActor
func refreshButtonTapped() {
    refreshTask = Task {
        try await refreshLaunches()
    }
}

The task belongs to the feature lifecycle, not to the short synchronous method. Unstructured tasks provide that flexibility.

03 · TUTORIAL

Lifetime and the Task Handle

The task can outlive the creating function

Consider a method that creates a task and immediately returns:

CODE EXAMPLE

func beginRefresh() {
    Task {
        let launches = try await launchAPI.fetchUpcomingLaunches()
        print(launches.count)
    }

    print("beginRefresh returned")
}

The method does not wait for the request. The new task can suspend during the network operation and resume later, after beginRefresh() has finished.

Conceptual model:

EXECUTION DIAGRAM

beginRefresh(): create task ─▶ return

new Task:          start ─▶ suspend ─────────▶ resume ─▶ finish

This diagram describes lifetime, not threads. The task’s eligible jobs are scheduled through executors and may use available system threads over time.

Important Terminology

Unstructured means the task’s lifetime is not automatically bounded by the lexical scope that created it. It does not mean the task is disorganised or unsafe by definition.

The handle represents the running operation

Task { } returns a typed handle.

CODE EXAMPLE

let task: Task<[Launch], Error> = Task {
    try await launchAPI.fetchUpcomingLaunches()
}

The two generic arguments describe the task’s success value and failure type. This task either returns [Launch] or throws an Error.

The handle supports the three operations an owner most often needs:

CODE EXAMPLE

let launches = try await task.value
let result = await task.result
task.cancel()

value waits for and returns the successful value or rethrows the task’s error. result waits for completion and packages the outcome as Result. cancel() marks the task as cancelled.

Discarding the handle does not cancel the task

The Task initializer allows its returned handle to be discarded.

CODE EXAMPLE

Task {
    try await analytics.recordLaunchViewed()
}

The task still runs even though no code retains the handle. Losing the handle does not cancel the operation.

It does remove the simplest way to:

• await the task’s value;

• observe its error;

• request cancellation;

• or connect the work to a feature lifecycle.

Fire-and-forget behaviour should therefore be deliberate. If completion, failure or cancellation matters, the task needs an owner and usually a retained handle.

04 · TUTORIAL

An Unstructured Task Is Not a Child Task

A task created with Task { } can inherit context from the point of creation, but it is not part of the creator’s structured child-task scope.

CODE EXAMPLE

func refresh() async throws {
    Task {
        try await cache.warm()
    }

    try await loadVisibleLaunches()
}

The cache task is not automatically awaited when refresh() returns. If the surrounding task is cancelled, that cancellation does not automatically propagate into this separately created unstructured task merely because it was created inside the function.

Conceptual comparison:

EXECUTION DIAGRAM

Structured child
Parent scope ─┬─ child begins ─ child finishes ─┬─ parent exits
              └─────────────────────────────────┘

Unstructured task
Creator scope ─ task begins ─ creator exits
                       └──────── task may continue ─────▶

Context inheritance and lifetime structure are different properties. Inheriting priority or actor isolation does not turn an unstructured task into a structured child.

05 · TUTORIAL

Creation Context and Execution

Task inherits its creation context

An unstructured task created with Task { } inherits important context from where it is created. This includes priority, task-local values and actor isolation when an actor-isolated context is present.

CODE EXAMPLE

@MainActor
final class LaunchFeature: ObservableObject {
    @Published private(set) var launches: [Launch] = []

    func beginRefresh() {
        Task {
            launches = try await loadLaunches()
        }
    }
}

The task closure inherits MainActor isolation. It can access launches directly because that state belongs to the same actor.

The awaited loadLaunches() call may suspend. While suspended, the task does not occupy MainActor. When actor-isolated execution is needed again, the task resumes through the main actor’s executor.

Important

Context inheritance is not background execution. Synchronous work performed inside this task while it is isolated to MainActor can still delay main-actor work.

The exact inheritance rules deserve their own article. For now, remember that Task { } begins with context; it does not erase the context around it.

Task does not mean background thread

This code is a common mistake:

Incorrect approach: the task inherits MainActor and performs heavy synchronous work there.

CODE EXAMPLE

@MainActor
func calculateVisibility() {
    Task {
        visibleLaunches = allLaunches.filter(expensiveVisibilityCheck)
    }
}

Creating the task does not automatically move the filter to a background thread. The task inherits the surrounding actor isolation, and the synchronous filter contains no suspension point.

The user can still observe delayed input or animation while that calculation occupies main-actor execution. Choosing an appropriate isolation and execution design for CPU-intensive work is separate from creating an unstructured task.

06 · TUTORIAL

Ownership Is More Important Than Syntax

Whenever code creates an unstructured task, ask who owns the operation.

EXECUTION DIAGRAM

Task owner
  ├─ stores the handle
  ├─ decides when the result is useful
  ├─ observes or handles failure
  ├─ requests cancellation
  └─ clears the handle when work finishes

A feature is a natural owner for work started by user interaction. An application service may own a long-lived listening task. A temporary local variable is sufficient when the current asynchronous operation immediately awaits the task’s value.

The mistake is not using Task { }. The mistake is creating work with a longer lifetime while leaving ownership implicit.

Retaining a task can also retain captured state

A task closure can capture its surrounding object. If that object also retains the task handle, the lifetime relationship deserves attention.

CODE EXAMPLE

final class LaunchListener {
    private var listeningTask: Task<Void, Never>?
    private let stream: LaunchStream

    func start() {
        listeningTask = Task {
            for await launch in stream.launches {
                process(launch)
            }
        }
    }

    func stop() {
        listeningTask?.cancel()
        listeningTask = nil
    }
}

The task uses the listener’s stream and process(_:), so it can keep the listener alive while the loop continues. For a finite task, that retention may end naturally. For a long-lived sequence, the owner needs an explicit stop policy.

Adding [weak self] is not a universal repair. The real design question is whether the task should keep the owner alive, whether the owner should cancel the task and where the loop should end.

07 · TUTORIAL

A Feature-Shaped Refresh Task

The launch feature can give its refresh task explicit ownership and prevent older work from publishing into a newer refresh.

CODE EXAMPLE

@MainActor
final class LaunchFeature: ObservableObject {
    @Published private(set) var launches: [Launch] = []
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let manager: LaunchManager
    private var refreshTask: Task<Void, Never>?
    private var activeRefreshID: UUID?

    init(manager: LaunchManager) {
        self.manager = manager
    }

    func refresh() {
        refreshTask?.cancel()

        let refreshID = UUID()
        activeRefreshID = refreshID
        isLoading = true

        refreshTask = Task {
            do {
                let loaded = try await manager.fetchLaunches()
                try Task.checkCancellation()
                guard activeRefreshID == refreshID else { return }

                launches = loaded
                errorMessage = nil
            } catch is CancellationError {
                // A newer refresh or lifecycle event owns the screen.
            } catch {
                guard activeRefreshID == refreshID else { return }
                errorMessage = "Could not load launches."
            }

            if activeRefreshID == refreshID {
                isLoading = false
                refreshTask = nil
            }
        }
    }

    func stopRefreshing() {
        refreshTask?.cancel()
        refreshTask = nil
        activeRefreshID = nil
        isLoading = false
    }
}

The synchronous refresh() method can start asynchronous work because Task { } creates a new task. The feature retains the handle because the refresh belongs to the feature lifecycle.

Starting again cancels the previous operation. The refresh ID prevents late completion or cleanup from an older task changing the state owned by a newer task. Cancellation and identity checks solve related but different problems.

08 · TUTORIAL

When Structured Concurrency Is Better

Do not create an unstructured task merely to avoid making a function asynchronous.

Weak design: the caller cannot observe completion or failure.

CODE EXAMPLE

func loadLaunches() {
    Task {
        try await manager.fetchLaunches()
    }
}

If the operation logically belongs to the caller, expose that relationship:

CODE EXAMPLE

func loadLaunches() async throws -> [Launch] {
    try await manager.fetchLaunches()
}

The caller can now await the value, propagate the error and include the call in its existing structured task tree.

Use Task { } at a genuine boundary: starting asynchronous work from synchronous code, or transferring ownership to a component whose lifetime extends beyond the current function.

09 · TUTORIAL

Unstructured Is Not the Same as Detached

Task { } and Task.detached { } both create unstructured tasks, but their relationship to the creation context differs.

Task { } inherits priority, task-local values and actor context when available.

Task.detached { } creates a more independent top-level task that does not inherit those values in the same way.

A detached task is not a generic escape hatch for actor errors and is not automatically safer. Its stricter independence, appropriate use cases and risks belong to the dedicated article later in this task cluster.


10 · TUTORIAL

The Complete Mental Model

Conceptual model:

EXECUTION DIAGRAM

Synchronous or asynchronous code
              │
              │ Task { operation }
              ▼
New unstructured task ───────▶ runs until completion
              │
              ▼
Typed task handle
  ├─ await value or result
  └─ request cancellation

Creating scope may return before the new task finishes

The new task has its own lifetime and begins with inherited context. It continues even if the handle is discarded. No lexical scope automatically awaits it, and cancellation from the creating task does not provide the downward propagation available to structured children.

That freedom creates one obligation: ownership must be visible in the architecture.


11 · TUTORIAL

What to Remember

Task { } creates a new unstructured task.

• The task’s lifetime is not bounded by the function that creates it.

• The returned handle exposes the task’s value, result and cancellation control.

• Discarding the handle does not cancel the task.

• An unstructured task is not automatically awaited when its creating scope exits.

• Cancellation does not automatically propagate from a creator into an unstructured task as it does to structured children.

Task { } inherits creation context, including actor isolation when present.

• Creating a task does not mean moving work to a background thread.

• A component that needs the result should own the task handle and lifecycle policy.

Task { } and Task.detached { } are both unstructured, but they have different inheritance behaviour.


12 · TUTORIAL

Frequently Asked Questions

Is Task { } structured concurrency?

No. The Task initializer creates an unstructured top-level task. Structured children are created with constructs such as async let and task groups, and their lifetimes are bounded by the creating scope.

Does an unstructured task stop when its function returns?

No. The task can continue after the creating function returns. It runs until it finishes or cooperatively responds to cancellation.

Does losing the Task handle cancel the task?

No. The task continues running even when the handle is discarded. Retain the handle when code needs to await the result, observe failure or request cancellation.

Does Task { } run on a background thread?

No. It creates asynchronous work and inherits surrounding actor context when present. Executors and the runtime determine which system threads execute eligible jobs.

Does cancellation propagate into an unstructured task?

Not automatically from the task that happened to create it. The owner should retain the new task’s handle and explicitly forward cancellation when that relationship is required.

When should I use an unstructured task?

Use one at a genuine lifecycle boundary, such as beginning asynchronous work from a synchronous event or creating work owned by a longer-lived feature. Prefer an ordinary async call or structured child when the work belongs to the current asynchronous operation.


14 · TUTORIAL

Continue Learning

Task { } inherits actor context, priority and task-local values even though it does not inherit a structured lifetime. The next article, What Does Task Inheritance Mean in Swift?, will examine each inherited value precisely and show why creation context changes what code inside a task is permitted and eligible to do.


15 · TUTORIAL

Download Xcode Playground

Use the accompanying Understanding Unstructured Tasks.playground to create a task from a synchronous launch action, inspect its typed handle and prove that it continues after the function returns. Then retain, await and cancel the task before building a main-actor launch feature that replaces stale refresh work safely.

bottom of page