ARC became easier for me to reason about when I stopped trying to predict an invisible reference count after every line. In one resource-loading feature, a downloaded image stayed alive after the request finished. The request was not the owner anymore, but a dictionary cache still was. The fix came from finding that ownership path, not from guessing where the compiler inserted a release.
The runnable project at the end recreates that kind of asset cache. It verifies strong references, collection ownership, zeroing weak references, weak cache entries, an unowned parent relationship, and explicit lifetime extension. These observable rules are more useful in application code than relying on private object layouts.
ARC Automates Ownership, Not Application Design
Automatic Reference Counting lets the compiler manage the retain and release operations needed by class instances. It is not a tracing garbage collector that periodically discovers every unreachable cycle. ARC follows the ownership expressed by strong, weak, and unowned references.
That automation removes manual memory-management calls from normal Swift code, but it cannot decide whether an image should remain cached or whether a callback should keep a screen model alive. Those are design choices represented by properties, local variables, collections, captures, and API boundaries.
I therefore ask a simple question during debugging: which strong reference currently owns this instance? If an object remains alive unexpectedly, at least one strong ownership path still reaches it. If it disappears too early, the code may have only weak or unowned access where a real owner was required.
A Strong Reference Is an Ownership Claim
The first project check creates an asset and gives it two strong variables:
var original: Asset? = Asset(
id: "hero",
bytes: [1, 2, 3],
lifetimeLog: log
)
weak var observedOriginal = original
var secondOwner = original
original = nil
Setting original to nil removes one owner, but secondOwner still keeps the instance alive. The weak observation remains non-nil. After the second strong variable is cleared, the asset deinitializes and the weak reference becomes nil.
I describe this in terms of owners rather than exact numeric counts. Frameworks, optimizer decisions, temporary values, and calling conventions make a sampled count a poor application-level debugging tool. What matters is whether a strong ownership path exists.
Collections Own the Objects They Store
The leak I see most often is not a mysterious compiler decision. It is an ordinary collection that was meant to be temporary. Arrays, dictionaries, sets, and many cache implementations hold their class instances strongly unless designed otherwise.
cache.storeStrongly(asset)
asset = nil
// The dictionary is now the owner.
let sameAsset = cache.strongAsset(id: "thumbnail")
cache.removeStrongAsset(id: "thumbnail")
The local variable can disappear while the cached asset remains available. Removing the final dictionary entry releases that ownership. This is correct if the cache has an eviction policy and incorrect if entries accumulate without a bound.
When a screen goes away but its models remain, I check singleton dictionaries, in-flight request registries, observer tokens, task collections, and closures before blaming ARC. These owners are easy to overlook because they live outside the screen’s source file.
Weak References Observe Without Owning
A weak reference does not extend an object’s lifetime. It is optional and becomes nil when the referenced instance deinitializes. The sample uses a small WeakAssetBox so a dictionary can index an asset without becoming its owner:
final class WeakAssetBox {
weak var asset: Asset?
init(_ asset: Asset) {
self.asset = asset
}
}
After the asset’s outside owner is cleared, looking it up through the weak box returns nil. This can be useful for registries and observer-style indexes where another component owns the real object.
A weak dictionary is not automatically a good image cache. If no other component owns an image, it may disappear immediately and provide no caching benefit. The choice depends on whether the collection is meant to own resources or merely find resources owned elsewhere.
Unowned Expresses a Stronger Lifetime Promise
An unowned reference also does not retain its target, but it is accessed as a non-optional reference. If the target has already deinitialized, using the unowned reference fails at runtime. I reserve it for relationships where the shorter lifetime is structurally clear.
final class DownloadBatch {
let name: String
lazy var summary = BatchSummary(batch: self)
}
final class BatchSummary {
unowned let batch: DownloadBatch
}
The batch owns its summary, and the summary points back without owning the batch. In the sample, callers use the summary only through its parent. That makes the lifetime invariant understandable.
If a summary could escape into a global cache or asynchronous task, the invariant would no longer hold. I would use weak access or redesign the value passed to the task. I do not choose unowned to avoid optional syntax or for a speculative speed improvement.
Deinitialization Is About Lifetime, Not Closing Braces
It is tempting to assume every local object remains alive until the closing brace of its lexical scope. Optimized Swift programs are allowed to end a strong reference’s lifetime after its last use. Code should not rely on an unused local variable keeping an object alive to a visually convenient line.
This does not mean an object is guaranteed to deinitialize immediately after the last source-level use either. ARC and optimization preserve program semantics, not a line-by-line lifetime schedule for debugging. A deinit log is useful evidence, but the source must express any lifetime requirement it depends on.
For asynchronous work, the reliable fix is normally to give the work an owner or capture the needed value with the intended strength. A weak reference is an observer, not a promise that the object will survive until a callback starts.
Use withExtendedLifetime for a Real Boundary
Occasionally code has a real requirement that an instance remain alive through a specific synchronous operation even though it is not otherwise used. The sample demonstrates that boundary with withExtendedLifetime:
withExtendedLifetime(temporary) {
temporary = nil
check(observedTemporary != nil)
}
check(observedTemporary == nil)
The value passed to the function remains alive through the body. After the body ends and no owner remains, the weak observation becomes nil. This makes the required lifetime part of the code instead of relying on a dummy read or a debugger side effect.
I use this rarely. It is appropriate around synchronous boundaries such as low-level resource access when lifetime itself is part of correctness. It does not make an object survive an asynchronous operation scheduled inside the closure after the closure returns.
Value Types Still Can Carry Reference Ownership
ARC manages class-instance references. Structs and enums have value semantics, but their stored properties can contain class references. Copying such a struct copies the reference value, so both struct values may participate in ownership of the same class instance.
This matters in models that wrap services, images, delegates, or storage objects. Calling the outer model a value type does not guarantee a deep copy of every referenced object inside it.
Closures are another ownership boundary. A stored escaping closure may keep captured class instances alive, and a closure stored by one of those instances can form a cycle. The same ownership-graph method applies: identify the object that stores the closure and follow its strong captures.
What I Check When Memory Looks Wrong
- Which variable, property, collection, closure, or task owns the instance?
- Is a cache intentionally strong, and when does it evict entries?
- Is a weak reference being mistaken for an owner?
- Can an unowned child escape beyond its parent’s lifetime?
- Does an escaping closure point back to the object that stores it?
- Does asynchronous work have a clear lifetime owner?
- Is an exact synchronous lifetime boundary actually required?
- Can a weak reference or deinit probe verify release in a test?
In the asset example, every result follows from these relationships. A second strong variable keeps an asset alive, a dictionary owns its entry, a weak box does not, an unowned summary avoids a parent-child cycle, and explicit lifetime extension protects one synchronous boundary.
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, third-party dependencies, or credentials.
git clone https://github.com/2252408699/swift-arc-asset-lifecycle-demo.git
cd swift-arc-asset-lifecycle-demo
swift run
A successful run prints eleven PASS checks followed by All ARC lifecycle checks passed. The checks cover multiple strong owners, deinitialization, strong and weak caches, unowned parent access, and a deliberately extended lifetime.
Complete source code: swift-arc-asset-lifecycle-demo on GitHub
