The New Weak-Capture Warning for Nested Closures
The New Weak-Capture Warning for Nested Closures
Upgrading to the Swift 6.4 toolchain surfaces a diagnostic you have probably never seen before, and a lot of the code that now triggers it has been compiling cleanly for years. The full weak self nested closure story is a single compiler warning followed by a quiet ownership bug:
ViewController.swift:41:37: warning: 'weak' ownership of capture 'self' differs
from implicitly-captured strong reference in outer scope
This warning is correct, and it fires on a real defect: [weak self] on an inner closure does not weaken anything, because capture lists are scoped to exactly one closure level. The outer closure has been capturing self strongly the whole time, silently defeating the weakify and pinning the instance in memory. Fixing it is cheap. The trap is fixing it the wrong way and shipping the opposite bug — premature deallocation — instead.
The Verdict
- The warning is correct. It reports an owner mismatch: the inner closure weakifies
selfwhile the outerescapingclosure that actually owns the reference graph retains it strongly. That is a genuine retain cycle closures source, not heuristic noise. - Fix it at the origin. Move the weak capture to the outermost closure that is being retained, or adopt a strong
selfwithguard letfor a bounded body. Both silence the warning without changing lifetime intent. - Do not blanket-weakify. Sprinkling
[weak self]at every nesting level trades a leak for the mirror-image defect: work silently dropped when an instance is deallocated mid-flight. The Swift weak capture warning is a signal to reason about closure lifetime, not a license to weaken everything.
What the compiler just caught
Capture lists do not propagate. A [weak self] in an inner closure must be fed by some self in its parent scope, and when the parent closure has no explicit capture of self, the compiler synthesizes a strong one. Here is the pattern that has shipped in, conservatively, thousands of apps — it builds cleanly on the 6.3 toolchain:
// CacheWarmer.swift — Swift 6.3: compiles with zero diagnostics.
final class CacheWarmer {
private let session: URLSession
private let cache: ImageCache
init(session: URLSession, cache: ImageCache) {
self.session = session
self.cache = cache
}
func warm(_ request: URLRequest) {
// This outer closure is escaping and retained by URLSession for the
// life of the task. I only weakified the inner hop — because the tab
// controller dismisses this thing fairly aggressively, so I "knew"
// one weak capture was enough. It was not.
let task = session.dataTask(with: request) { data, _, error in
DispatchQueue.main.async { [weak self] in
guard let data, error == nil else { return }
self?.cache.store(data)
}
}
task.resume()
}
}
The failure is invisible in a diff review. [weak self] is present, so the author’s stated intent — “do not retain the warmer” — appears honored. In reality the dataTask closure implicitly captures self strongly, URLSession retains that closure for the task’s lifetime, and the instance that should have deallocated when the controller dismissed is still live, still holding session and cache. Instruments reports it as abandoned memory: reachable, retained, and never going away. The Swift 6.4 weak self nested closure warning names the exact line where the mismatch lives.
Why the strong capture appears silently
The mechanics matter, because they explain why the fix has to sit at a specific nesting level rather than anywhere. The inner [weak self] binding does not materialize a weak reference out of nothing — it binds a weak copy of the self value visible in the surrounding scope. The surrounding scope is the body of the outer closure, and that body has no self unless the outer closure’s capture list provides one. The compiler fills the gap with an implicit strong capture, which the diagnostic group calls ImplicitStrongCapture.
That means the retain graph runs through the outer closure, not the inner one:
URLSession
└── retains → dataTask closure (escaping, stored)
└── implicitly captures → self (STRONG — the leak)
└── supplies → inner async closure's [weak self]
The inner closure is never the problem. It is a faithful weak consumer of a self that the outer closure holds strongly for the entire task duration. No amount of additional [weak self] decoration on inner hops repairs the graph, because they all read from the same poisoned parent scope.
The fix: weakify at the origin
Because the outer closure is the retained owner, that is where the weak capture belongs. CacheWarmer.warm does not use self in its outer body at all — it only forwards to the inner hop — so the capture list legitimately moves up one level:
func warm(_ request: URLRequest) {
let task = session.dataTask(with: request) { [weak self] data, _, error in
// 'self' here is already the weak optional the inner closure needs.
DispatchQueue.main.async {
guard let data, error == nil else { return }
self?.cache.store(data)
}
}
task.resume()
}
The outer closure now holds a weak reference, and the inner closure reads that same weak value through lexical scope. URLSession retains a closure that no longer retains the warmer; the task can complete against a deallocated instance without leaking it. Warning gone, intent honored.
When the outer closure also needs self for its own work, keep the weak capture at the origin and adopt a strong self after the guard for the bounded remainder — the classic weak/strong/weak dance, still valid under Swift 6.4:
func warm(_ request: URLRequest, annotate: Bool) {
let task = session.dataTask(with: request) { [weak self] data, _, error in
guard let self else { return } // bounded strong body
self.stats.record(condition: error == nil)
DispatchQueue.main.async { [weak self] in // re-weak the cross-thread hop
guard let data else { return }
self?.cache.store(data)
}
}
task.resume()
}
Here the outer [weak self] stops the cycle at URLSession, the guard promotes to a strong local for the synchronous work inside the task, and the inner hop re-weakifies because crossing to MainActor adds a new, separate retention window. The inner [weak self] no longer warns, because the parent scope provides an explicit capture rather than an implicit strong one.
The deliberate opt-out
Strong capture is not always a bug. A Task closure that completes in bounded time is not a cycle source, and forcing it weak silences nothing — it just adds a nil-check. The clean declaration of intent is an explicit strong capture list entry, which also tells the diagnostic you have read the graph:
func refreshIfExpired() {
// This Task is launched and completes within one run loop hop; it is not
// stored anywhere, so a strong self here cannot form a cycle. Declaring
// [self] states that explicitly instead of papering over it with weak.
Task { [self] in
guard await cache.isStale else { return }
try await session.preload()
}
}
The explicit [self] is the compiler-supported way to say “yes, I meant the strong capture” — the same mechanism the fix-it offers as an alternative to moving the capture.
The hidden cost: over-weakening is the mirror bug
The warning is correct, but it makes the easy fix too available. Once the compiler starts flagging nested closures, the mechanical response is to add [weak self] wherever a closure nest appears — including places where the instance is the only thing keeping in-flight work alive. The result is premature deallocation, which is functionally worse than the leak it replaced:
// Over-weakened: the user taps "Retry", the view model is dismissed, and
// the retry chain dies at the first nil check. No error, no UI feedback —
// the request simply never happens. The leak version at least completed work.
func retryUpload() {
let task = session.uploadTask(with: queuedRequest) { [weak self] _, _, _ in
guard let self else { return } // nil if screen dismissed
Task { [weak self] in // second, pointless hop
guard let self else { return }
await self.reEnqueue()
}
}
task.resume()
}
Every additional guard let self is a silent early-return site. A view controller that legitimately deallocates on dismissal turns from “retains itself briefly to finish a background retry” into “nobody finishes the retry.” The weak capture warning exists to eliminate unintended strong retention, not to make strong capture illegal — and the correct engineering decision is a function of the closure’s storage and lifespan:
| Closure kind | Lifetime | Correct capture |
|---|---|---|
Stored in a property, retained by self or a peer (timer, delegate, task store) | Outlives the graph | weak at the origin (where the graph is rooted), or this is a real retain cycle |
Task { } launched for bounded work, not stored | Completes then releases | strong is correct and explicit with [self] |
Escaping, ephemeral (URLSession, DispatchQueue.main.async) | Bounded, releases on completion | weak at origin if the instance may disappear; strong if you need the work to survive |
Evaluate the closure that is actually stored and escaping — that is the one that pins memory. Weakify that origin once, adopt strong for bounded work, and leave inner hops to inherit the weak value or to re-weak only when they cross into a new retention domain. That distribution is cheaper than blanket recovery: fewer guard let self sites, fewer dropped-work paths, and the retain cycle closures class of bugs eliminated at the source rather than patched symptom-by-symptom.
The 2026 toolchain finally turned a seven-year-old footgun into a one-line diagnostic. The discipline the warning asks for — deciding where ownership actually lives instead of decorating every closure — is the same discipline Instruments would have demanded after a week of generational analysis.
Internal Links
- Advanced Memory Management: Beyond Weak and Unowned — the ARC side-table machinery behind weak references and why weakening costs more than a nil-check.
- Actor Reentrancy: Solving the Suspension Gap in Swift 6 — closure lifetimes and state integrity when work spans suspension points.
- withTaskCancellationShield: Structured Cleanup Under Cancellation — the sibling deep dive on keeping bounded cleanup reliably alive inside task closures.
- @isolated(any): Isolation-Aware Function Types — when callbacks inherit the caller’s isolation, the closure graph and its capture semantics change.