Tasks
Use this when:
- You need to start async work from synchronous code.
- You are choosing between
Task,async let, and task groups. - You need cancellation, priorities, or structured vs unstructured guidance.
Skip this file if:
- The problem is mainly actor isolation or sendability. Use
actors.mdorsendable.md. - The work is stream-shaped. Use
async-sequences.mdorasync-algorithms.md.
Jump to:
- What is a Task?
- Cancellation
- Task Groups
- Discarding Task Groups
- Advanced: Task Timeout Pattern
- SwiftUI Integration
- Structured vs Unstructured Tasks
- Task Priorities
What is a Task?
Tasks bridge synchronous and asynchronous contexts. They start executing immediately upon creation—no resume() needed.
func synchronousMethod() {
Task {
await someAsyncMethod()
}
}Task entry isolation
Task { ... } inherits the enclosing isolation domain. This is especially easy to miss in modules that use defaultIsolation(MainActor.self) because bare tasks then start on @MainActor by default.
Choose task entry isolation using the synchronous prefix rule (everything before the first await):
- If the prefix needs main-actor work, keep inherited
@MainActorentry. - If the prefix does not need main actor, prefer
Task { @concurrent in ... }and hop back only for UI mutation.
// ❌ Prefix has no main-actor work; first await hops away
Task {
await someActor.refresh()
}
// ✅ Prefix needs @MainActor; keep inherited main start
Task {
print("debug") // trivial non-main line rides along
self.isLoading = true // main-actor state before first await
await fetchData()
}For deeper guidance and expanded examples, see threading.md#choosing-task-entry-isolation.
Task References
Storing a reference is optional but enables cancellation and result waiting:
final class ImageLoader {
var loadTask: Task<UIImage, Error>?
func load() {
loadTask = Task {
try await fetchImage()
}
}
deinit {
loadTask?.cancel()
}
}Tasks run regardless of whether you keep a reference.
Course Deep Dive: This topic is covered in detail in Lesson 3.1: Introduction to tasks in Swift Concurrency
Cancellation
Checking for cancellation
Tasks must manually check for cancellation:
// Throws CancellationError if canceled
try Task.checkCancellation()
// Boolean check for custom handling
guard !Task.isCancelled else {
return fallbackValue
}Where to check
Add checks at natural breakpoints:
let task = Task {
// Before expensive work
try Task.checkCancellation()
let data = try await URLSession.shared.data(from: url)
// After network, before processing
try Task.checkCancellation()
return processData(data)
}Child task cancellation
Canceling a parent automatically notifies all children:
let parent = Task {
async let child1 = work(1)
async let child2 = work(2)
let results = try await [child1, child2]
}
parent.cancel() // Both children notifiedChildren must still check Task.isCancelled to stop work.
Course Deep Dive: This topic is covered in detail in Lesson 3.2: Task cancellation
Error Handling
Task error types are inferred from the operation:
// Can throw
let throwingTask: Task<String, Error> = Task {
throw URLError(.badURL)
}
// Cannot throw
let nonThrowingTask: Task<String, Never> = Task {
"Success"
}Awaiting results
do {
let result = try await task.value
} catch {
// Handle error
}Handling errors internally
let safeTask: Task<String, Never> = Task {
do {
return try await riskyOperation()
} catch {
return "Fallback value"
}
}Course Deep Dive: This topic is covered in detail in Lesson 3.3: Error handling in Tasks
SwiftUI Integration
The .task modifier
Automatically manages task lifetime with view lifecycle:
struct ContentView: View {
@State private var data: Data?
var body: some View {
Text(data?.description ?? "Loading...")
.task {
data = try? await fetchData()
}
}
}Task cancels automatically when view disappears.
Reacting to value changes
.task(id: searchQuery) {
await performSearch(searchQuery)
}When searchQuery changes:
- Previous task cancels
- New task starts with updated value
Course Deep Dive: This topic is covered in detail in Lesson 3.12: Running tasks in SwiftUI
Priority configuration
// High priority (default for SwiftUI)
.task(priority: .userInitiated) {
await fetchUserData()
}
// Lower priority for background work
.task(priority: .low) {
await trackAnalytics()
}Task Groups
Dynamic parallel task execution with compile-time unknown task count.
Basic usage
await withTaskGroup(of: UIImage.self) { group in
for url in photoURLs {
group.addTask {
await downloadPhoto(url: url)
}
}
}Collecting results
let images = await withTaskGroup(of: UIImage.self) { group in
for url in photoURLs {
group.addTask { await downloadPhoto(url: url) }
}
return await group.reduce(into: []) { $0.append($1) }
}Error handling
let images = try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in photoURLs {
group.addTask { try await downloadPhoto(url: url) }
}
// Iterate to propagate errors
var results: [UIImage] = []
for try await image in group {
results.append(image)
}
return results
}Critical: Errors in child tasks don't automatically fail the group. Use iteration (for try await, next(), reduce()) to propagate errors.
Course Deep Dive: This topic is covered in detail in Lesson 3.5: Task Groups
Early termination on error
try await withThrowingTaskGroup(of: Data.self) { group in
for id in ids {
group.addTask { try await fetch(id) }
}
// First error cancels remaining tasks
while let data = try await group.next() {
process(data)
}
}Cancellation
await withTaskGroup(of: Result.self) { group in
for item in items {
group.addTask { await process(item) }
}
// Cancel all remaining tasks
group.cancelAll()
}Or prevent adding to canceled group:
let didAdd = group.addTaskUnlessCancelled {
await work()
}Discarding Task Groups
For fire-and-forget operations where results don't matter:
await withDiscardingTaskGroup { group in
group.addTask { await logEvent("user_login") }
group.addTask { await preloadCache() }
group.addTask { await syncAnalytics() }
}Benefits
- More memory efficient (doesn't store results)
- No
next()calls needed - Automatically waits for completion
- Ideal for side effects
Error handling
try await withThrowingDiscardingTaskGroup { group in
group.addTask { try await uploadLog() }
group.addTask { try await syncSettings() }
}
// First error cancels group and throwsReal-world pattern: Multiple notifications
extension NotificationCenter {
func notifications(named names: [Notification.Name]) -> AsyncStream<()> {
AsyncStream { continuation in
let task = Task {
await withDiscardingTaskGroup { group in
for name in names {
group.addTask {
for await _ in self.notifications(named: name) {
continuation.yield(())
}
}
}
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
// Usage
for await _ in NotificationCenter.default.notifications(
named: [.userDidLogin, UIApplication.didBecomeActiveNotification]
) {
refreshData()
}Course Deep Dive: This topic is covered in detail in Lesson 3.6: Discarding Task Groups
Structured vs Unstructured Tasks
Structured (preferred)
Bound to parent, inherit context, automatic cancellation:
// async let
async let data1 = fetch(1)
async let data2 = fetch(2)
let results = await [data1, data2]
// Task groups
await withTaskGroup(of: Data.self) { group in
group.addTask { await fetch(1) }
group.addTask { await fetch(2) }
}Course Deep Dive: This topic is covered in detail in Lesson 3.7: The difference between structured and unstructured tasks
Unstructured (use sparingly)
Independent lifecycle, manual cancellation:
// Regular task (unstructured but inherits priority)
let task = Task {
await doWork()
}
// Detached task (completely independent)
Task.detached(priority: .background) {
await cleanup()
}Detached Tasks
Use as last resort. They don't inherit:
- Priority
- Task-local values
- Cancellation state
Task.detached(priority: .background) {
await DirectoryCleaner.cleanup()
}When to use
- Independent background work
- No connection to parent needed
- Acceptable to complete after parent cancels
- No
selfreferences needed
Prefer: Task groups or async let for most parallel work.
Course Deep Dive: This topic is covered in detail in Lesson 3.4: Detached Tasks
Task Priorities
Available priorities
.high // Immediate user feedback
.userInitiated // User-triggered work (same as .high)
.medium // Default for detached tasks
.utility // Longer-running, non-urgent
.low // Similar to .background
.background // Lowest prioritySetting priority
Task(priority: .background) {
await prefetchData()
}Priority inheritance
Structured tasks inherit parent priority:
Task(priority: .high) {
async let result = work() // Also .high
await result
}Detached tasks don't inherit:
Task(priority: .high) {
Task.detached {
// Runs at .medium (default)
}
}Priority escalation
System automatically elevates priority to prevent priority inversion:
- Actor waiting on lower-priority task
- High-priority task awaiting
.valueof lower-priority task
Course Deep Dive: This topic is covered in detail in Lesson 3.8: Managing Task priorities
Task.sleep() vs Task.yield()
Task.sleep()
Suspends for fixed duration, non-blocking:
try await Task.sleep(for: .seconds(5))Use for:
- Debouncing user input
- Polling intervals
- Rate limiting
- Artificial delays
Respects cancellation (throws CancellationError)
Task.yield()
Temporarily suspends to allow other tasks to run:
await Task.yield()Use for:
- Testing async code
- Allowing cooperative scheduling
Note: If current task is highest priority, may resume immediately.
Practical: Debounced search
func search(_ query: String) async {
guard !query.isEmpty else {
searchResults = allResults
return
}
do {
try await Task.sleep(for: .milliseconds(500))
searchResults = allResults.filter { $0.contains(query) }
} catch {
// Canceled (user kept typing)
}
}
// In SwiftUI
.task(id: searchQuery) {
await searcher.search(searchQuery)
}Course Deep Dive: This topic is covered in detail in Lesson 3.10: Task.yield() vs. Task.sleep()
async let vs TaskGroup
| Feature | async let | TaskGroup |
|---|---|---|
| Task count | Fixed at compile-time | Dynamic at runtime |
| Syntax | Lightweight | More verbose |
| Cancellation | Automatic on scope exit | Manual via cancelAll() |
| Use when | 2-5 known parallel tasks | Loop-based parallel work |
// async let: Known task count
async let user = fetchUser()
async let settings = fetchSettings()
let profile = Profile(user: await user, settings: await settings)
// TaskGroup: Dynamic task count
await withTaskGroup(of: Image.self) { group in
for url in urls {
group.addTask { await download(url) }
}
}Advanced: Task Timeout Pattern
Create timeout wrapper using task groups:
func withTimeout<T>(
_ duration: Duration,
operation: @Sendable @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: duration)
throw TimeoutError()
}
guard let result = try await group.next() else {
throw TimeoutError()
}
group.cancelAll()
return result
}
}
// Usage
let data = try await withTimeout(.seconds(5)) {
try await slowNetworkRequest()
}cancelAll() is critical — without it, the losing task keeps running until scope exit.
Task.sleep throws CancellationError when the task is cancelled, making it a useful cancellation checkpoint in polling loops. Task.yield() only gives other tasks a chance to run and does not check cancellation — if the current task has the highest priority, it may resume immediately.
Course Deep Dive: This topic is covered in detail in Lesson 3.14: Creating a Task timeout handler using a Task Group (advanced)
Common Patterns
Sequential with early exit
let user = try await fetchUser()
guard user.isActive else { return }
let posts = try await fetchPosts(userId: user.id)Parallel independent work
async let user = fetchUser()
async let settings = fetchSettings()
async let notifications = fetchNotifications()
let data = try await (user, settings, notifications)Mixed: Sequential then parallel
let user = try await fetchUser()
async let posts = fetchPosts(userId: user.id)
async let followers = fetchFollowers(userId: user.id)
let profile = Profile(
user: user,
posts: try await posts,
followers: try await followers
)Common Mistakes Agents Make
- Replacing structured child work with many unrelated top-level tasks.
- Using
Task.detachedjust to "make it background." - Ignoring cancellation in long-running operations.
- Keeping a stored task forever without a clear owner or cleanup path.
- Picking entry isolation from the enclosing context rather than the task's synchronous prefix —
Task { await someActor.x() }from a@MainActorcontext should beTask { @concurrent in ... }; aTaskwhose prefix mutates@MainActorstate should stay on inherited@MainActoreven if it also has aprint. - Priorities are hints, not guarantees. The system automatically elevates priority to prevent inversion (e.g., a high-priority task awaiting
.valueof a lower-priority task). Do not rely on priority for correctness.
Best Practices
- Check cancellation regularly in long-running tasks
- Use structured concurrency (avoid detached tasks)
- Leverage SwiftUI's
.taskmodifier for view-bound work - Choose the right tool:
async letfor fixed, TaskGroup for dynamic - Handle errors explicitly in throwing task groups
- Set priority only when needed (inherit by default)
- Don't mutate task groups from outside their creation context
Further Learning
For hands-on examples, advanced patterns, and migration strategies, see Swift Concurrency Course.