100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Swift

Task and Structured Concurrency

Explore how Swift's Task type creates units of asynchronous work, how structured concurrency establishes parent-child relationships, and how cancellation propagates.

Concurrency & CombineIntermediate10 min readJul 8, 2026
Analogies

Task and Structured Concurrency

Task is the fundamental unit of asynchronous work in Swift concurrency — creating a Task { ... } starts a new asynchronous context that can call await expressions, independent of the surrounding synchronous code. Structured concurrency is the broader principle that async work should form a tree: child tasks are scoped to their parent's lifetime, so when a parent task is cancelled or completes, its children are cancelled too, and a parent cannot finish until its children have. This is a deliberate contrast to older, unstructured patterns like fire-and-forget DispatchQueue.global().async {} blocks, whose lifetime and cancellation had no relationship to the code that created them.

🏏

Cricket analogy: A DRS review is scoped to the current over — it can't outlive the match, and if the match is abandoned every pending review is cancelled with it, unlike an old-style unofficial sideline bet with no tie to the game's lifetime.

Unstructured Task Creation

A plain Task { ... } initializer creates what's technically an *unstructured* task — it inherits the current actor context and priority but is not tied to any enclosing scope, so it keeps running even if the code that created it returns. In SwiftUI, the .task view modifier is the idiomatic way to tie a task's lifetime to a view: it starts the task when the view appears and automatically cancels it when the view disappears, which is exactly the behavior you usually want for view-driven data loading.

🏏

Cricket analogy: A commentator starting an unstructured side analysis keeps talking even after the broadcast cuts away, but SwiftUI's .task on a live-scorecard view is like a feed that starts when you open the match screen and stops when you leave it.

swift
struct ArticleListView: View {
    @State private var articles: [Article] = []
    @State private var isLoading = false

    var body: some View {
        List(articles) { article in
            Text(article.title)
        }
        .overlay { if isLoading { ProgressView() } }
        .task {
            isLoading = true
            defer { isLoading = false }
            do {
                articles = try await ArticleService.shared.fetchAll()
            } catch is CancellationError {
                // View disappeared before the fetch finished; nothing to do.
            } catch {
                print("Failed to load articles: \(error)")
            }
        }
    }
}

Task Groups for Structured Concurrency

When you need to launch a dynamic number of child tasks and wait for all of them, withTaskGroup (or withThrowingTaskGroup for tasks that can fail) provides true structured concurrency: every child task added to the group is guaranteed to either complete or be cancelled before the group's enclosing call returns, and cancelling the group cancels every child. This differs from async let, which is structured but fixed at compile time to a specific number of bindings.

🏏

Cricket analogy: withTaskGroup is like a fielding coach dynamically assigning a variable number of fielders to review multiple camera angles of a run-out at once, guaranteeing every review finishes or is cancelled before the umpire's decision returns.

swift
func fetchThumbnails(for urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else {
                    throw URLError(.cannotDecodeContentData)
                }
                return image
            }
        }
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Cancellation

Swift concurrency's cancellation is cooperative: calling task.cancel() (or a view disappearing while .task is running) sets a flag but does not forcibly stop execution. Long-running async work should periodically check Task.isCancelled or call try Task.checkCancellation(), which throws a CancellationError if the task has been cancelled, so the work can unwind cleanly. Many system APIs, like URLSession's async methods, already check cancellation internally and throw automatically.

🏏

Cricket analogy: Calling a DRS review cancel doesn't instantly stop the ball-tracking replay mid-frame; the system must check a cancellation flag between frames and throw, like URLSession's async methods that check cancellation and stop cleanly on their own.

Structured concurrency is often compared to a well-run kitchen: a head chef (the parent task) delegates dishes to line cooks (child tasks), but the head chef cannot declare the meal complete until every delegated dish is either finished or explicitly called off — no dish is ever left cooking unattended after the meal is done, unlike a fire-and-forget kitchen where cooks might keep working on orders nobody wants anymore.

A Task { ... } created inside a SwiftUI action (like a button's closure) is unstructured and outlives the view if the view disappears — it will keep running and can even mutate @State after the view is gone, which triggers a runtime warning. Prefer .task for view-lifecycle-bound work, and only use bare Task { } for genuinely fire-and-forget actions like analytics logging.

  • Task { ... } creates a new asynchronous context; SwiftUI's .task modifier ties a task's lifetime to a view's appearance/disappearance.
  • Structured concurrency means child tasks cannot outlive their parent scope — the parent waits for all children to finish or be cancelled.
  • withTaskGroup/withThrowingTaskGroup let you launch a dynamic number of structured child tasks and collect their results.
  • async let is also structured concurrency, but limited to a fixed, compile-time-known set of bindings.
  • Cancellation in Swift concurrency is cooperative — code must check Task.isCancelled or call Task.checkCancellation() to respond to it.
  • Bare Task { } blocks are unstructured and can outlive the view that created them, unlike .task.

Practice what you learned

Was this page helpful?

Topics covered

#Swift#IOSWithSwiftUIStudyNotes#MobileDevelopment#TaskAndStructuredConcurrency#Task#Structured#Concurrency#Unstructured#StudyNotes#SkillVeris