When working with Swift’s structured concurrency, you’ll encounter the concept of suspension points—moments where a task might pause its execution to allow other work to run. Understanding where and why these happen is crucial for writing safe, efficient, and predictable concurrent code.

What is a Task Suspension Point?

A task suspension point occurs when a task pauses its execution so that the system can perform other work—like waiting for a network request or letting another task run. In Swift, this most commonly happens when you use the await keyword.

Every time you write:

let data = await fetchData()

There’s potentially a suspension point. The task may yield execution control while waiting for fetchData() to complete, allowing other tasks (including UI updates or background work) to progress in the meantime.

But here’s an important nuance. I’m not using bold often in my text, but I did in the previous paragraph for a reason: Using await doesn’t always mean the task will be suspended.

If the awaited operation can be completed synchronously, the task won’t be suspended at all—it will continue executing immediately. This could potentially happen when awaiting an asynchronous method on the same isolation domain. The await keyword simply marks a possible suspension point, not a guaranteed one.

Why should I bother thinking about suspension points?

Knowing where suspension points can occur is critical because:

  1. Your code may pause unexpectedly. After a suspension point, Swift can resume your task at a later time, potentially on a different thread.
  2. Side effects around suspension points can lead to bugs. Any mutable state or in-progress work that assumes uninterrupted execution might behave incorrectly if the task is paused mid-way.

Supension points can also increase risks of retain cycles, but we’ll discuss these in detail in the next module.

Suspension Points and Actor Reentrancy

This brings us to an especially important implication: actor reentrancy.

Actors in Swift protect their isolated state by ensuring only one task accesses it at a time. But if a suspension point happens inside an actor-isolated method, Swift allows other tasks waiting on that actor to sneak in and run their work before the original task resumes.

Let’s look at an example:

actor BankAccount {
    private var balance: Int = 0

    func deposit(amount: Int) async {
        balance += amount
        print("[\(balance)] Deposited \(amount), balance is now \(balance)")

        await logTransaction(amount) // ⚠️ Suspension point

        balance += 10 // bonus after logging
        print("[\(balance)] Applied bonus, balance is now \(balance)")
    }

    private func logTransaction(_ amount: Int) async {
        try? await Task.sleep(for: .seconds(1)) // Simulate async logging.
        print("[logTransaction] Logged deposit of \(amount)")
    }
}

Imagine calling it as follows:

struct ActorReentrancyDemonstrator {
    func demonstrate() {
        let account = BankAccount()

        Task {
            await withTaskGroup(of: Void.self) { group in
                for _ in 1...2 {
                    group.addTask {
                        await account.deposit(amount: 100)
                    }
                }
            }
        }
    }
}

You would probably expect the sequential increments like 100 → 110 → 210 → 220. However, due to interleaving, we’re ending up with the following print statements:

[100] Deposited 100, balance is now 100
[200] Deposited 100, balance is now 200
[logTransaction] Logged deposit of 100
[210] Applied bonus, balance is now 210
[logTransaction] Logged deposit of 100
[220] Applied bonus, balance is now 220

In other words, we go from 100 → 200 → 210 → 220.

This demonstrates how a suspension point within an actor can result in unexpected behavior. When the task suspends to log the transaction, another task might slip in and access or mutate the balance before the bonus is applied. This reentrancy can lead to subtle bugs like race conditions or inconsistent states.

You can avoid unintended reentrancy within an actor by minimizing or eliminating suspension points while modifying the actor’s state. In our example, the fix is as simple as logging the transaction at the end of the deposits:

func deposit(amount: Int) async {
    balance += amount
    print("[\(balance)] Deposited \(amount), balance is now \(balance)")

    balance += 10 // bonus after logging
    print("[\(balance)] Applied bonus, balance is now \(balance)")
    await logTransaction(amount) // ⚠️ Suspension point
}

Which results in the expected outcome:

[100] Deposited 100, balance is now 100
[110] Applied bonus, balance is now 110
[210] Deposited 100, balance is now 210
[220] Applied bonus, balance is now 220
[logTransaction] Logged deposit of 100
[logTransaction] Logged deposit of 100

Summary

Understanding suspension points helps you reason about the flow of your concurrent code and avoid tricky bugs, especially in actor-isolated contexts. Always ask yourself: Could something else happen between this line and the next?

A suspension point occurs when a task might yield execution control, most commonly marked by the use of await. Not every await leads to suspension; some operations complete synchronously without yielding. Inside an actor, suspension points can introduce actor reentrancy, allowing other tasks to interleave and access the actor’s state before your original task resumes. To avoid subtle bugs, avoid mutating actor state after a suspension point, or gather necessary values into local variables beforehand.

By being mindful of where suspension points exist in your code, you can write safer, more predictable concurrent programs.

The next lesson will cover how to control the underlying thread with proper instructions to Swift Concurrency.