The New Weak-Capture Warning for Nested Closures

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 self while the outer escaping closure 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 self with guard let for 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 kindLifetimeCorrect capture
Stored in a property, retained by self or a peer (timer, delegate, task store)Outlives the graphweak at the origin (where the graph is rooted), or this is a real retain cycle
Task { } launched for bounded work, not storedCompletes then releasesstrong is correct and explicit with [self]
Escaping, ephemeral (URLSession, DispatchQueue.main.async)Bounded, releases on completionweak 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.

Ready for more depth?

Master these concepts with our structured technical roadmap.

View Roadmap