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
| Feature | Structured | Unstructured |
|---|---|---|
| Automatic cancellation | ✅ | ❌ |
| Lifetime management | Compiler-enforced | Manual |
| Can outlive scope | ❌ | ✅ |
| From sync context | ❌ | ✅ |
| Risk of leaks | None | High |
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.