Concurrency Deep Dive

Structured vs Unstructured Concurrency

February 18, 2026
5 min read
Featured image for blog post: Structured vs Unstructured Concurrency

Task { } vs async let vs TaskGroup. When do you use which?

Follow along with the code: iOS-Practice on GitHub

Structured Concurrency

Structured concurrency ties task lifetime to scope. When the scope ends, child tasks are automatically cancelled and awaited.

async let

func loadUserProfile() async -> Profile {
    async let avatar = fetchAvatar()
    async let posts = fetchPosts()
    async let friends = fetchFriends()

    // All three run concurrently
    // All must complete before function returns
    return Profile(
        avatar: await avatar,
        posts: await posts,
        friends: await friends
    )
}

If loadUserProfile is cancelled, all three child tasks are cancelled automatically. You can't "leak" a task—it's impossible for fetchAvatar() to keep running after the function returns.

TaskGroup

func loadAllImages(_ urls: [URL]) async -> [Data] {
    await withTaskGroup(of: Data?.self) { group in
        for url in urls {
            group.addTask {
                try? await fetchImage(from: url)
            }
        }

        var results: [Data] = []
        for await result in group {
            if let data = result {
                results.append(data)
            }
        }
        return results
    }
}

The group waits for all child tasks. If the parent is cancelled, all children are cancelled. No task escapes the withTaskGroup block.

Unstructured Concurrency

Unstructured tasks live independently. They're not tied to any scope.

Task { }

func startBackgroundSync() {
    Task {
        while !Task.isCancelled {
            await syncData()
            try? await Task.sleep(nanoseconds: 60_000_000_000)
        }
    }
}

This task runs until explicitly cancelled—or forever if you forget. It outlives the function, the view, potentially the whole feature.

Task.detached { }

func logAnalytics(_ event: String) {
    Task.detached(priority: .background) {
        await AnalyticsService.log(event)
    }
}

Detached tasks don't inherit the parent's priority or actor context. Use sparingly—they're the most "unstructured" option.

When to Use Each

Use async let when:

  • You have a fixed number of concurrent operations
  • All operations must complete before proceeding
  • You want automatic cancellation
// Perfect for async let: known count, all needed
async let user = fetchUser(id)
async let settings = fetchSettings(id)
return (await user, await settings)

Use TaskGroup when:

  • You have a dynamic number of operations
  • You want to process results as they arrive
  • You need to limit concurrency
// Perfect for TaskGroup: unknown count, streaming results
await withTaskGroup(of: Image.self) { group in
    for url in imageURLs {  // Could be 5 or 500
        group.addTask { await loadImage(url) }
    }
    for await image in group {
        display(image)
    }
}

Use Task { } when:

  • Work should outlive the current scope
  • You need to start async work from synchronous context
  • The task is tied to object lifetime (not scope lifetime)
// Perfect for Task: fire-and-forget from sync context
@IBAction func refreshTapped() {
    refreshTask?.cancel()
    refreshTask = Task {
        await viewModel.refresh()
    }
}

Use Task.detached when:

  • You explicitly don't want to inherit actor context
  • Background work that shouldn't affect caller's priority
  • Almost never—think twice before using

The Trade-offs

FeatureStructuredUnstructured
Automatic cancellation
Lifetime managementCompiler-enforcedManual
Can outlive scope
From sync context
Risk of leaksNoneHigh

Converting Unstructured to Structured

Before reaching for Task { }, ask: can this be structured?

// Unstructured (risky)
class ViewModel {
    func load() {
        Task { items = await fetchItems() }
    }
}

// Structured (safe) - use .task modifier
struct ItemsView: View {
    @State private var items: [Item] = []

    var body: some View {
        List(items) { ... }
            .task {
                items = await fetchItems()
            }
    }
}

The .task modifier creates a structured task tied to the view's lifecycle. When the view disappears, the task is cancelled. No manual tracking needed.

The Rule

Prefer structured concurrency. Use async let for fixed concurrent work, TaskGroup for dynamic work. Only reach for Task { } when you genuinely need the work to outlive the current scope—and when you do, track it and cancel it properly.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch