Task(priority: .background) can still run on the main actor. Priority doesn’t select a thread. I got that distinction wrong in the original version of this note.

Updated 7 October 2026. Examples checked with Swift 6.4 in Swift 6 language mode. The fetch functions below are placeholders for your own asynchronous APIs.

Tasks, actors and threads

Task {} creates an unstructured task, not a child task. Its closure inherits the surrounding actor isolation. Inside a @MainActor function, changing the priority doesn’t remove that isolation:

@MainActor
func startWork() -> Task<Void, Never> {
    Task(priority: .background) {
        MainActor.assertIsolated()
    }
}

Task.detached is also unstructured, but doesn’t inherit the caller’s actor isolation, priority or task-local values. It isn’t a promise of a dedicated background thread, and calling an actor-isolated function can still hop to that actor.

Use async let or a task group when the work should be a child task with a lifetime bounded by the enclosing scope. With either form of unstructured task, you manage its lifetime and cancellation yourself.

How to run concurrently

Using async let, you can start a fixed number of child tasks concurrently. That allows their work to overlap; it doesn’t guarantee simultaneous execution on different threads.

async let listing = fetchListing()
async let favorites = fetchFavorites()

// Then await for both
let all = await (listing, favorites)

How to run concurrently with TaskGroup

TaskGroup lets you create a number of child tasks determined at runtime. The group waits for its children before leaving its scope.

// This task group will return the UIImage types
try await withThrowingTaskGroup(of: UIImage.self) { group in
    for url in urls {
        group.addTask {
            return try await fetchImage(from: url)
        } 
    }

    for try await image in group {
        // Handle as each image is fetched
    }
}

If you only need to wait for completion, use Void and discard each result. Keep try await so a fetch failure reaches the caller:

try await withThrowingTaskGroup(of: Void.self) { group in
    for url in urls {
        group.addTask {
            _ = try await fetchImage(from: url)
        }
    }
    try await group.waitForAll()
}

How to limit the number of concurrent running tasks

Let’s say you want to fetch at most 5 images at a time. Use next() to collect a completed result before adding another task, then collect the remaining results. The images arrive in completion order, not URL order.

let images = try await withThrowingTaskGroup(of: UIImage.self) { group in
    var images: [UIImage] = []
    for (i, url) in urls.enumerated() {
        if i >= 5, let image = try await group.next() {
            images.append(image)
        }
        group.addTask {
            return try await fetchImage(from: url)
        } 
    }
    for try await image in group {
        images.append(image)
    }
    return images
}

Cancellation

Task groups use cooperative cancellation. Your work must respond by checking Task.isCancelled, calling try Task.checkCancellation(), or using an API that already responds to cancellation.

addTaskUnlessCancelled only avoids adding a child when the group is already cancelled. A child that has started still needs to cooperate with later cancellation.