Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Swift Concurrency Course

Immediate tasks in Swift Concurrency explained

Immediate tasks in Swift Concurrency are new since SE-472 was adopted in Swift 6.2. They are a solution for cases where you want to prevent the initial delay caused by creating and scheduling a task. You might need this when the task to perform is minimal work, or when you know we’re already on the proper actor but not yet in an asynchronous context.

Before diving into the details, I’d like to encourage you to only use this when you’re really sure you need it. It might sound tempting to start tasks right away to get the result as fast as possible, but in many cases, it’s more than fine to rely on the standard concurrency execution schedule. Let’s dive into the details, so you know what I’m talking about.

Starting a task immediately

Immediate tasks allow you to create a task that runs synchronously in the caller’s execution context. A regular Task gets created and scheduled to run later, while an immediate task starts executing right away until it reaches its first actual suspension point.

This sounds like a small difference, but it can matter in performance-sensitive code or synchronous APIs that are already running on the right actor.

You can create an immediate task using Task.immediate:

func startLoading() {
    Task.immediate {
        await loadInitialData()
    }
}

The task begins executing synchronously on the caller’s executor. This means the first part of the task runs before startLoading() continues, up to the first suspension point that actually suspends.

This is different from a regular task:

func startLoading() {
    Task {
        await loadInitialData()
    }

    print("This can run before loadInitialData starts")
}

With a regular Task, Swift schedules the operation to run later. In many cases, that is exactly what you want. It avoids blocking the caller and gives the runtime room to schedule work efficiently.

However, there are cases where this extra scheduling step is not ideal. If the work is tiny, might return early, or needs to update actor-isolated state before the caller continues, Task.immediate can be a better fit.

Running until the first suspension

The most important rule is that an immediate task only runs synchronously until it truly suspends. The await keyword marks a potential suspension point, but not every await suspends in practice.

func configureView() {
    Task.immediate {
        guard shouldShowPlaceholder else {
            return
        }

        await updatePlaceholderIfNeeded()
        await loadRemoteContent()
    }

    print("Configuration continued")
}

If shouldShowPlaceholder returns false, the task finishes before configureView() continues. If updatePlaceholderIfNeeded() does not suspend, the task keeps running synchronously. Once loadRemoteContent() performs an actual suspension, the caller can continue executing, and the task will resume later according to its isolation.

In other words, Task.immediate gives you a synchronous start, not a guarantee that the whole task runs synchronously.

FREE 5-day email course: The Swift Concurrency Playbook by Antoine van der Lee

FREE 5-Day Email Course: The Swift Concurrency Playbook

A FREE 5-day email course revealing the 5 biggest mistakes iOS developers make with with async/await that lead to App Store rejections And migration projects taking months instead of days (even if you've been writing Swift for years)

Calling async code from synchronous actor code

A useful case shows up when you are in a synchronous function that is already running on the right actor. You might know this from a framework callback or a synchronous helper called from @MainActor code.

Imagine a synchronous method called from a main-actor isolated context:

@MainActor
final class PhotoSelectionModel {
    private(set) var selectedPhotoID: UUID?

    func selectPhoto(id: UUID) async {
        selectedPhotoID = id
        await persistSelection(id)
    }

    private func persistSelection(_ id: UUID) async {
        // Store the selection in a database or on disk.
    }
}

You cannot call selectPhoto(id:) directly from a synchronous function because it is async. A regular Task works, but it runs later:

@MainActor
func didTapPhoto(id: UUID, model: PhotoSelectionModel) {
    Task {
        await model.selectPhoto(id: id)
    }

    // The selectedPhotoID might not be updated yet.
}

If ordering matters, you can use an immediate task:

@MainActor
func didTapPhoto(id: UUID, model: PhotoSelectionModel) {
    Task.immediate {
        await model.selectPhoto(id: id)
    }

    // The task started immediately on the MainActor.
}

In this example, the task starts on the MainActor right away. The selectedPhotoID update happens before the caller continues, assuming selectPhoto(id:) does not suspend before that assignment.

This is a subtle but important detail. If selectPhoto(id:) awaited before updating selectedPhotoID, the caller could continue before the state changes.

Requesting a specific actor

You can also write the actor isolation explicitly inside the task closure:

func handleLegacyCallback(id: UUID, model: PhotoSelectionModel) {
    Task.immediate { @MainActor in
        await model.selectPhoto(id: id)
    }
}

This asks Swift to run the task on the MainActor. If the current executor already matches the MainActor, the task starts immediately. If not, Swift falls back to the usual behavior and enqueues the work to run later.

This makes Task.immediate useful for legacy callbacks that are often called from the main actor, while still staying safe when they are not.

Note that Task.immediate { @MainActor in ... } does not crash when you are not on the MainActor. It simply enqueues the task like a regular task. Use MainActor.assumeIsolated only when you truly need a runtime assertion and can guarantee the caller’s isolation.

Avoiding overhang

The biggest risk of immediate tasks is overhang: running too much synchronous work on the caller executor before the task suspends.

This is especially risky on the main actor:

@MainActor
func handleSearchQuery(_ query: String) {
    Task.immediate {
        let results = expensiveLocalSearch(query)
        await updateResults(results)
    }
}

If expensiveLocalSearch(_:) takes 300 milliseconds, your UI is blocked for 300 milliseconds. A regular Task would still run on the main actor if it inherited that isolation, but the important point is that Task.immediate makes the blocking occur before the caller regains control.

I recommend only using immediate tasks when the synchronous first part is known to be small:

  • Updating a small amount of actor-isolated state
  • Returning early after a cheap condition check
  • Starting an async call from a synchronous context while preserving ordering

If the work can be expensive, keep using a regular Task, move the work off the actor, or make the surrounding API async instead.

Immediate detached tasks

Swift also provides Task.immediateDetached, which is the immediate version of Task.detached.

func cleanupTemporaryFiles() {
    Task.immediateDetached(priority: .background) {
        await TemporaryFileCleaner.cleanup()
    }
}

The same warning from the detached tasks lesson applies here: detached tasks do not inherit actor context, task-local values, or cancellation from the caller in the same way regular tasks do. The immediate part only changes where the task starts executing. It does not make detached tasks structured or automatically managed.

In practice, Task.immediateDetached should be rare. If Task.detached is already your last resort, the immediate detached variant is even more specific.

Immediate tasks in task groups

Task groups also gain immediate variants:

await withTaskGroup(of: Thumbnail.self) { group in
    for photo in photos {
        group.addImmediateTask {
            await generateThumbnail(for: photo)
        }
    }

    for await thumbnail in group {
        await store(thumbnail)
    }
}

addImmediateTask behaves like addTask, but the child task starts immediately on the caller’s executor until its first actual suspension. The child task is still part of the group, so structured concurrency semantics, such as cancellation and awaiting results, still apply.

There is one pitfall to keep in mind: child tasks in task groups do not automatically inherit the actor isolation of the enclosing context. On top of that, if the immediate child task does a lot of synchronous work before suspending, the loop that adds tasks can effectively become serial.

await withTaskGroup(of: Void.self) { group in
    for item in items {
        group.addImmediateTask {
            performExpensiveSynchronousWork(for: item)
        }
    }
}

The example above appears concurrent, but each immediate child task can run its synchronous work before the next task is added. If you want parallelism for synchronous CPU work, addImmediateTask is likely the wrong tool.

Use immediate child tasks when the first part is cheap and ordering matters. Use regular addTask for the usual task-group parallelism.

Conclusion

Immediate tasks allow you to start a task synchronously on the caller’s execution context. They are useful when you need to execute async code from a synchronous function while preserving ordering, especially when working with actor-isolated state. The key takeaway is that Task.immediate changes occur where a task starts, not the task’s overall lifecycle. Use immediate tasks sparingly. They can block the caller executor until the first actual suspension point, and immediate task-group children can accidentally make work serial.

Swift Concurrency is much more than just immediate tasks, so I’d love to invite you to check out www.swiftconcurrencycourse.com for an in-depth course on Swift 6.2 & Swift Concurrency.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.