Introduction
In Swift, closures are reference types that capture and store references to any constants and variables from their surrounding scope. When a closure stored as a property captures self, and self holds a reference to that closure, a retain cycle is created. Neither object can be deallocated, causing a memory leak that grows with each instance created.
Symptoms
deinitis never called on view controllers or view models- Memory usage grows continuously during navigation
- Instruments > Leaks shows cycles between objects and closures
- App crashes with memory warning after extended use
os_logshows objects accumulating in memory
Example retain cycle: ```swift class ViewModel { var onDataLoaded: (([Item]) -> Void)?
func load() { // Retain cycle: closure captures self, self stores closure onDataLoaded = { [self] items in // Strong capture! self.items = items self.updateUI() } }
deinit { // Never called because of retain cycle print("ViewModel deinit") } } ```
Common Causes
- Escaping closures capturing
selfwithoutweakorunowned - Callback properties storing closures that reference the owner
- Timer targets and notification observers not removed
- Delegate patterns using closures instead of protocols
- Async operations with completion handlers holding strong references
Step-by-Step Fix
- 1.Use weak self in escaping closures:
- 2.```swift
- 3.class ViewModel {
- 4.var onDataLoaded: (([Item]) -> Void)?
func load() { onDataLoaded = { [weak self] items in self?.items = items self?.updateUI() } } } ```
- 1.Identify the capture graph:
- 2.```swift
- 3.// Enable capture debugging
- 4.class ViewModel {
- 5.deinit {
- 6.print("ViewModel deinit - \(ObjectIdentifier(self))")
- 7.}
func setup() { // Log what the closure captures print("Setting up closure, captures: self=\(ObjectIdentifier(self))") networkManager.fetch { [weak self] result in guard let self = self else { print("self was deallocated before callback fired") return } print("Callback fired, self is alive") self.process(result) } } } ```
- 1.Use unowned when you are certain about lifetime:
- 2.```swift
- 3.class TimerWrapper {
- 4.private var timer: Timer?
func start() { // unowned is safe here because timer is invalidated in deinit timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [unowned self] _ in self.tick() } }
deinit { timer?.invalidate() } } ```
- 1.Detect cycles with Xcode Memory Graph Debugger:
- 2.
` - 3.Run the app in Xcode
- 4.Navigate to a screen and then back (popping the VC)
- 5.Click the Memory Graph button in the Debug navigator
- 6.Search for the class name (e.g., "ViewModel")
- 7.If instances still exist, check the reference arrows for cycles
- 8.
` - 9.Break the cycle explicitly:
- 10.```swift
- 11.class NetworkManager {
- 12.private var completionHandlers: [String: (Result<Data, Error>) -> Void] = [:]
func cancelAll() { // Break all references to completion handlers completionHandlers.removeAll() }
deinit { cancelAll() // Ensure no closures hold references } } ```
Prevention
- Default to
[weak self]in all escaping closures - Verify
deinitis called for every important class - Use Xcode Memory Graph Debugger regularly during development
- Run Instruments > Leaks before releasing features
- Avoid storing closures as properties when possible; use delegate protocols instead
- Add unit tests that verify deallocation:
XCTAssertNil(weakRef)