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

  • deinit is 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_log shows 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 self without weak or unowned
  • 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. 1.Use weak self in escaping closures:
  2. 2.```swift
  3. 3.class ViewModel {
  4. 4.var onDataLoaded: (([Item]) -> Void)?

func load() { onDataLoaded = { [weak self] items in self?.items = items self?.updateUI() } } } ```

  1. 1.Identify the capture graph:
  2. 2.```swift
  3. 3.// Enable capture debugging
  4. 4.class ViewModel {
  5. 5.deinit {
  6. 6.print("ViewModel deinit - \(ObjectIdentifier(self))")
  7. 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. 1.Use unowned when you are certain about lifetime:
  2. 2.```swift
  3. 3.class TimerWrapper {
  4. 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. 1.Detect cycles with Xcode Memory Graph Debugger:
  2. 2.`
  3. 3.Run the app in Xcode
  4. 4.Navigate to a screen and then back (popping the VC)
  5. 5.Click the Memory Graph button in the Debug navigator
  6. 6.Search for the class name (e.g., "ViewModel")
  7. 7.If instances still exist, check the reference arrows for cycles
  8. 8.`
  9. 9.Break the cycle explicitly:
  10. 10.```swift
  11. 11.class NetworkManager {
  12. 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 deinit is 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)