Swift

Swift Closures Explained: @escaping, Non-Escaping, and Trailing Closures with Code

By Seren  |  21 Sep, 2026  |  Leave a comment


I understood Swift closure syntax long before I understood closure lifetime. I could write map, completion handlers, and trailing closures without thinking about them. The gap only became obvious when a profile screen refused to deallocate after it was dismissed.

The screen asked an avatar pipeline to load an image. The pipeline stored a completion handler until the request finished, and the handler captured the view model. Because the view model also owned the pipeline, those three ordinary decisions formed a retain cycle. This article rebuilds that bug in a small command-line project and then fixes it without turning [weak self] into a rule that must be added everywhere.

The Profile Screen That Stayed Alive

The first symptom was not a crash. The screen disappeared normally, but its view model’s deinit message never appeared. Reopening the screen created another view model. That made the problem easy to overlook during a quick manual test because the user interface still behaved correctly.

I reduced the code until only an AvatarPipeline and a ProfileViewModel remained. The pipeline stored callbacks in a dictionary keyed by request ID. The view model owned the pipeline. Its callback referred to self so it could save the returned file name.

func loadWithStrongCapture() -> UUID {
    pipeline.requestAvatar { result in
        if case let .success(fileName) = result {
            self.avatarFileName = fileName
        }
    }
}

The ownership path was now visible: the view model retained the pipeline, the pipeline retained the stored closure, and the closure retained the view model. Dismissing the screen removed the outside reference, but the cycle kept all three parts alive.

What Non-Escaping Actually Guarantees

A closure parameter is non-escaping by default. That means the function cannot store the closure or return it in a way that allows it to be called after the function has finished. It does not mean the closure must always be called. A function may choose a branch that never invokes it, as long as the closure does not escape the call.

A synchronous transformation is a good example. The following function uses the closure while building its return value. Once the function returns, it no longer has access to the closure:

func transformNames(
    _ names: [String],
    using transform: (String) -> String
) -> [String] {
    names.map(transform)
}

let normalized = transformNames(["  Ana ", " BO "]) {
    $0.trimmingCharacters(in: .whitespaces).lowercased()
}

The compiler can reason about this limited lifetime and allow operations that are not available to an escaping parameter. I avoid promising a particular stack or heap allocation, though. That is an optimization detail, and optimized builds may represent closures differently. The language-level lifetime rule is the dependable part.

Why the Pipeline Needs @escaping

The avatar pipeline does not call the completion before requestAvatar returns. It places the closure in a dictionary so another method can invoke it later. That stored lifetime is exactly what @escaping communicates:

private var completions: [UUID: Completion] = [:]

func requestAvatar(completion: @escaping Completion) -> UUID {
    let id = UUID()
    completions[id] = completion
    return id
}

func finish(_ id: UUID, fileName: String) {
    let completion = completions.removeValue(forKey: id)
    completion?(.success(fileName))
}

Asynchronous APIs commonly need escaping closures, but “asynchronous” and “escaping” are not interchangeable definitions. Storage is the clearer test. A closure may escape without doing network work or changing threads. Conversely, an API can perform work through concurrency mechanisms without exposing an escaping closure parameter to its caller.

The annotation is not a warning that the closure is dangerous. It tells the caller that captured values may live beyond the current function call. Ownership still depends on what the closure captures and who retains it.

Escaping Does Not Automatically Mean a Retain Cycle

An escaping closure can capture a local value and disappear cleanly after one call. It can also capture self safely when the closure is owned by an independent short-lived operation. A cycle appears only when a strong ownership path leads from self back to the closure that strongly captures it.

That distinction changed how I review callback code. I no longer search for every occurrence of @escaping and add weak captures mechanically. I draw the ownership path instead. Who stores the closure? For how long? Does self own that storage, directly or indirectly? When is the stored closure removed?

In the demo, removing the completion from the dictionary breaks the strong cycle and allows deinit to run. This also shows why cancellation cleanup matters. Even if the request never finishes, its completion should not remain stored forever.

Using weak self for the Screen Lifetime

For this screen, the result is no longer useful after the view model disappears. A weak capture expresses that requirement directly:

func loadWithWeakCapture() -> UUID {
    pipeline.requestAvatar { [weak self] result in
        if case let .success(fileName) = result {
            self?.avatarFileName = fileName
        }
    }
}

Now the pipeline may keep the completion, but the completion does not keep the view model alive. If the screen is dismissed first, self becomes nil and the result is ignored. That is the behavior I want for a user-interface update.

I use unowned only when the lifetime relationship makes a missing owner impossible. An incorrect unowned assumption turns a normal timing change into a runtime crash. For callbacks tied to screens, cancellation, navigation, and slow responses make a weak reference the more honest model in many cases.

Trailing Closures Change Syntax, Not Ownership

Trailing closure syntax lets the final closure argument sit outside the parentheses. It makes call sites easier to scan, especially when the closure contains several lines. It does not make a closure escaping or non-escaping. The function declaration still determines that.

let requestID = pipeline.requestAvatar { result in
    print(result)
}

Multiple trailing closures follow the same rule. Labels can make success and failure paths readable, but they do not alter capture semantics. I judge these APIs by their call-site clarity and lifecycle contract, not by whether the braces appear inside or outside parentheses.

How the Runnable Test Exposes the Cycle

The sample uses a weak reference to observe a view model without keeping it alive. In the first test, the view model registers a strongly capturing callback and then leaves its local scope. The weak reference remains non-nil, proving that something still owns the object. Cancelling the request removes the stored closure; the weak reference becomes nil and the deinitialization flag changes.

The second test registers the weak-capture version. When the local scope ends, the view model deallocates immediately even though the pipeline still holds the callback. The executable performs eight checks covering the transformation, stored completion, strong cycle, cancellation cleanup, and weak-capture behavior.

This kind of small lifecycle test is more useful than relying on memory folklore. It verifies the behavior that matters to the application without pretending to know every optimizer decision behind the compiled closure.

The Checklist I Use in Callback Code

  • Mark a closure @escaping only when the function allows it to outlive the call.
  • Find the owner that stores each escaping closure.
  • Draw the strong-reference path before deciding how to capture self.
  • Remove stored completions after success, failure, or cancellation.
  • Use weak capture when the work may safely finish without the owner.
  • Use unowned capture only when the lifetime guarantee is real.
  • Treat trailing closures as call-site syntax, not a lifetime feature.
  • Test deallocation instead of assuming that a capture list fixed it.

The important lesson from the profile-screen bug was not “always use weak self.” It was to make callback ownership visible. Once I knew where the closure was stored and when it was removed, the correct capture choice stopped being guesswork.

Download and Run the Example

The example is a Swift Package for macOS. With Swift 5.9 or later installed, run:

git clone https://github.com/2252408699/swift-closure-lifecycle-demo.git
cd swift-closure-lifecycle-demo
swift run

A successful run prints eight PASS lines followed by All closure lifecycle checks passed.

Complete source code: swift-closure-lifecycle-demo on GitHub

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *