Swift

Swift Retain Cycles: Three Solutions and When to Use Each

By Seren  |  29 Sep, 2026  |  2 Comments


I stopped adding [weak self] to every Swift closure after debugging a download screen that stayed alive after it was closed. The leak was real, but the useful lesson was not that closures are dangerous. The problem was a specific ownership loop: the screen model owned a progress source, the source stored a closure, and that closure could own the screen model.

Once I drew those three arrows, the fix was straightforward. It also made me notice that weak, unowned, and explicit callback cleanup solve different lifetime problems. The runnable project at the end demonstrates all three in a small download workflow and verifies when each object is released.

A Retain Cycle Is an Ownership Loop

ARC releases a class instance when no strong references keep it alive. A cycle appears when a chain of strong references eventually points back to its starting object. Each reference can look reasonable on its own, but the group has no outside owner that can break the loop.

In the sample, DownloadScreenModel owns a ProgressSource. The source stores an onProgress closure. If that closure captures the screen model strongly, the relationship becomes screen model to source, source to closure, and closure back to screen model.

A closure that mentions self is not automatically a leak. There must also be a strong path that keeps the closure and leads back to self. A short-lived closure owned only by an operation may intentionally keep its dependencies alive until the operation completes.

Solution 1: weak for Independent Lifetimes

The progress source may live longer than the screen model. A download can continue after a screen closes, so the callback must tolerate the screen model disappearing. That is a good reason for a weak capture:

func startObserving() {
    source.onProgress = { [weak self] percent in
        guard let self else { return }
        latestText = "Downloaded \(percent)%"
    }
}

A weak reference does not keep the instance alive and becomes nil after deallocation. That is why self is optional inside the closure. The guard creates a strong local reference for the current invocation, so the model remains available while the body updates its state.

The executable sends a 40 percent update while the model exists, then leaves its scope. A weak reference confirms that the model deallocated even though the progress source and stored callback still exist. Sending another update safely does nothing.

Optional Chaining or guard Depends on the Work

For one independent update, self?.latestText = ... is concise. When several statements must operate on the same live object, I prefer guard let self else { return }. It makes the all-or-nothing behavior visible and keeps a stable strong reference for that execution.

That guard does not turn a weak capture into a permanent strong capture. The strong reference exists only in the closure invocation. After the call returns, it is released normally.

I also decide what a missing owner means. Silently returning is correct for a screen update after dismissal. It may be wrong for a payment, file write, or database transaction that must finish regardless of whether a view still exists. In those cases, the operation should be owned by a longer-lived service rather than by the screen.

Solution 2: unowned for a Strict Lifetime Invariant

An unowned reference also avoids retaining its target, but it is not optional. Accessing it after the target has deallocated is a runtime error. I use it only when the design guarantees the referenced owner outlives the object holding the unowned reference.

final class DownloadBatch {
    let name: String
    lazy var reporter = BatchReporter(batch: self)
}

final class BatchReporter {
    unowned let batch: DownloadBatch

    init(batch: DownloadBatch) {
        self.batch = batch
    }
}

The batch strongly owns its reporter. The reporter exists only as part of that batch and points back with unowned. Callers do not keep the reporter after releasing the batch. Under that stated invariant, an optional weak reference would add a state the model does not intend to support.

I do not choose unowned as a performance shortcut. The decision is about lifetime certainty. If the child can escape, be cached, or execute asynchronously after the owner disappears, the invariant is false and weak or a different ownership design is safer.

Solution 3: Release a One-Shot Callback at Its Boundary

Sometimes a callback should deliberately keep its owner alive until one result arrives. A document loader may need to finish updating its model even if the original local variable goes away. Weak capture would cancel that update implicitly.

The sample lets the completion capture its loader strongly, but the request clears the stored callback before invoking it:

func resolve(with value: String) {
    let callback = completion
    completion = nil
    callback?(value)
}

Moving the callback to a local variable keeps it alive during the call. Setting the property to nil first removes the request’s long-term ownership. When the local callback finishes, the cycle is gone and the loader can deallocate.

This approach needs a reliable terminal boundary: success, failure, cancellation, timeout, or explicit teardown. If some path forgets cleanup, the cycle remains. I use it for true one-shot APIs and write tests for every terminal path.

Why Clearing Before Calling Matters

Calling the property and clearing it afterward looks similar, but it leaves the stored ownership in place while user code runs. The callback could re-enter the request, trigger cancellation, or cause another result. Taking a local copy and clearing first makes the one-shot rule explicit.

Real asynchronous code may also need synchronization so two threads cannot take the same completion simultaneously. The small command-line project resolves synchronously to focus on ownership. In a concurrent request type, I would protect the take-and-clear operation with the component’s serialization mechanism.

This is a case where a strong capture is intentional. The question is not whether the capture is strong, but whether the ownership has a guaranteed end.

Delegates, Timers, Tasks, and Observers Are Different Cases

A delegate property is commonly weak when the delegating object should not own its delegate, but that convention is not a universal language rule. The API’s ownership contract still matters.

Repeating timers and stored callbacks deserve special attention because their lifetime is open-ended. Cancellation or invalidation is often as important as the capture list. A one-shot task that the owner does not store may have no cycle even when its closure captures the owner strongly.

Notification APIs also differ. Selector-based registrations, closure-based observer tokens, and newer concurrency-based sequences do not all have identical ownership behavior. I inspect the specific API and store or remove tokens according to its contract instead of assuming every observer relationship leaks.

How I Verify That a Screen Really Deallocates

I start with a reproducible path: open the screen, begin the operation, close the screen, and repeat. A temporary deinit log or a weak reference in a test tells me whether the instance remains alive. The sample uses weak references as executable lifetime assertions.

If an instance remains, I inspect the memory graph and follow strong ownership paths back from the object. I look for stored closures, timers, tasks, caches, parent-child references, observer tokens, and collections. The graph matters more than guessing which line contains self.

A growing memory total alone does not prove a retain cycle. Caches, decoded images, allocator behavior, and delayed cleanup can keep memory high. I verify the lifetime of the specific instances that should have disappeared.

The Review Questions I Use Now

  • Who owns this closure, task, timer, or observer token?
  • Does the closure strongly capture an object that leads back to it?
  • May the callback outlive the screen or model it updates?
  • Is a missing owner a normal state or a programming error?
  • Is an unowned lifetime guarantee documented and structurally enforced?
  • Does a one-shot callback clear on success, failure, cancellation, and timeout?
  • Can callback cleanup race with delivery?
  • Does a lifetime test prove the important object deallocates?

For the download progress callback, weak matches independent lifetimes. For the batch reporter, unowned expresses a strict parent-child relationship. For the one-shot document request, explicit cleanup preserves intentional lifetime until the result and then ends the cycle.

Download and Run the Complete Example

The complete example is a Swift Package for macOS and requires Swift 5.9 or later. It uses only the standard library and Foundation, with no network requests, external dependencies, or credentials.

git clone https://github.com/2252408699/swift-retain-cycle-download-workflow.git
cd swift-retain-cycle-download-workflow
swift run

A successful run prints nine PASS checks followed by All retain-cycle checks passed. The checks cover progress delivery, weak deallocation, surviving callbacks, an unowned child, one-shot strong capture, callback cleanup, and repeat resolution.

Complete source code: swift-retain-cycle-download-workflow 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 *

2 Comments