Leaks rarely announce themselves. Instead, they slowly raise your memory footprint, cause jank during scrolling or animation, and eventually trigger mysterious crashes under pressure. This guide is your one-stop, bookmark-worthy reference to understand, find, and fix leaks across UIKit, SwiftUI, and hybrid codebases.

At the core sits ARC (Automatic Reference Counting). ARC increases an instance's retain count when something holds a strong reference to it, and decreases the count when that reference goes away. When the count hits zero, the object deallocates. Leaks happen when that count never reaches zero — most commonly due to strong reference cycles.

👉R &qut;You can read the full article here (no membership needed).

1) The Root of All Evil: Understanding Strong Reference Cycles

ARC is essentially a counter per class instance:

  • strong: increments the count (default).
  • weak: does not increment; becomes nil automatically when the object deallocates; must be optional.
  • unowned: does not increment; assumes the object outlives the reference (crashes if accessed after deallocation).

A crystal-clear, non-UI example

// MARK: - Leaky pair (cycle)
final class Person {
    let name: String
    var apartment: Apartment?
    init(name: String) { self.name = name; print("Person \(name) init") }
    deinit { print("Person \(name) deinit") }
}

final class Apartment {
    let unit: String
    // ❌ Strong back-reference completes the cycle with person.apartment
    var tenant: Person?
    init(unit: String) { self.unit = unit; print("Apartment \(unit) init") }
    deinit { print("Apartment \(unit) deinit") }
}
func leakDemo() {
    var john: Person? = Person(name: "John")
    var unit101: Apartment? = Apartment(unit: "101")
    john?.apartment = unit101
    unit101?.tenant = john        // Person <-> Apartment strong cycle
    john = nil                     // ❌ Still retained
    unit101 = nil                  // ❌ Still retained
}
// MARK: - Fixed pair
final class FixedApartment {
    let unit: String
    weak var tenant: Person?       // ✅ Breaks the cycle
    init(unit: String) { self.unit = unit; print("FixedApartment \(unit) init") }
    deinit { print("FixedApartment \(unit) deinit") }
}
func fixedDemo() {
    var jane: Person? = Person(name: "Jane")
    var unit202: FixedApartment? = FixedApartment(unit: "202")
    unit202?.tenant = jane         // weak -> no cycle
    jane = nil                     // ✅ deinit prints
    unit202 = nil                  // ✅ deinit prints
}

Rule of thumb: If A strongly retains B and B strongly retains A, neither deallocates. Break one side with weak or (rarely, and safely) unowned.

2) The Usual Suspects: Classic Memory Leaks in UIKit

Scenario 1: The Delegate Pattern Trap

Why it leaks: A UIViewController strongly owns its root view. If that view strongly owns a delegate and the controller sets itself as the delegate, you create ViewController → View → ViewController.

Leaky code:

import UIKit

protocol CustomViewDelegate {                   // ❌ Not class-constrained
    func didTapAction()
}
final class CustomView: UIView {
    var delegate: CustomViewDelegate?           // ❌ Strong by default
    private let button = UIButton(type: .system)
    override init(frame: CGRect) {
        super.init(frame: frame)
        button.setTitle("Tap", for: .normal)
        button.addTarget(self, action: #selector(tap), for: .touchUpInside)
        addSubview(button)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) not impl") }
    @objc private func tap() { delegate?.didTapAction() }
}
final class LeakyViewController: UIViewController, CustomViewDelegate {
    override func loadView() {
        let v = CustomView()
        v.delegate = self                        // ❌ Cycle formed here
        view = v
        print("LeakyViewController init")
    }
    deinit { print("LeakyViewController deinit") }
    func didTapAction() { print("Tapped!") }
}

Fix (one line + protocol constraint):

import UIKit

protocol CustomViewDelegate: AnyObject {        // ✅ Class-constrained
    func didTapAction()
}
final class FixedCustomView: UIView {
    weak var delegate: CustomViewDelegate?      // ✅ Breaks the cycle
    // ... same UI setup as before
}
final class FixedViewController: UIViewController, CustomViewDelegate {
    override func loadView() {
        let v = FixedCustomView()
        v.delegate = self
        view = v
        print("FixedViewController init")
    }
    deinit { print("FixedViewController deinit") }
    func didTapAction() { print("Tapped!") }
}

Why weak works: The view no longer increments the delegate's retain count. When the controller should deallocate, it can—no cycle remains.

Scenario 2: The Escaping Closure Trap

Why it leaks: A service stores a completion closure. Your view controller owns the service. The closure captures self strongly. Result: VC → Service → Closure → VC.

Leaky code:

import UIKit

final class NetworkService {
    var completion: ((String) -> Void)?        // stored escaping closure
    func fetch() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.completion?("OK")
            // If not cleared, the closure (and potentially VC) stick around
        }
    }
}
final class LeakyClosureVC: UIViewController {
    private let service = NetworkService()
    override func viewDidLoad() {
        super.viewDidLoad()
        print("LeakyClosureVC init")
        service.completion = { result in       // ❌ Implicit strong capture of self
            self.title = result
        }
        service.fetch()
    }
    deinit { print("LeakyClosureVC deinit") }
}

Fixed code:

final class FixedClosureVC: UIViewController {
    private let service = NetworkService()

    override func viewDidLoad() {
        super.viewDidLoad()
        print("FixedClosureVC init")
        service.completion = { [weak self, weak service] result in  // ✅ weak self
            guard let self = self else { return }
            self.title = result
            service?.completion = nil   // ✅ Clear the reference once used
        }
        service.fetch()
    }
    deinit { print("FixedClosureVC deinit") }
}

Why this works: The closure doesn't bump the VC's retain count, and the stored reference is nulled out once the work finishes.

3) Modern Traps: Sneaky Memory Leaks in SwiftUI

SwiftUI views are value types (struct)—great! But the state they point to is often reference types (class), such as ObservableObject. That's where cycles sneak back in.

Scenario 1: ObservableObject + Combine Cycle

Why it leaks: sink closures capture self strongly by default. If you store the resulting AnyCancellable in self, you can form ViewModel → AnyCancellable → Closure → ViewModel.

Leaky code:

import SwiftUI
import Combine

final class TickerViewModel: ObservableObject {
    @Published var tick: Int = 0
    private var cancellables = Set<AnyCancellable>()
    init() {
        print("TickerViewModel init")
        Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { _ in                      // ❌ Captures self strongly
                self.tick += 1
            }
            .store(in: &cancellables)         // retained by self
    }
    deinit { print("TickerViewModel deinit") }
}
struct TickerView: View {
    @StateObject private var vm = TickerViewModel()
    var body: some View { Text("Tick: \(vm.tick)").padding() }
}

Fix A (weak capture):

Timer.publish(every: 1, on: .main, in: .common)
    .autoconnect()
    .sink { [weak self] _ in                  // ✅ weak self breaks the cycle
        self?.tick += 1
    }
    .store(in: &cancellables)

Fix B (avoid closure capture with assign):

Timer.publish(every: 1, on: .main, in: .common)
    .autoconnect()
    .map { _ in 1 }
    .scan(0, +)
    .assign(to: &$tick)                       // ✅ No closure capturing self

Also ensure: Any explicit callback properties on your ObservableObject use [weak self] in the view, or better, publish state changes instead of calling back.

Scenario 2: .task + @StateObject Lifetime Trap

Why it leaks: Work launched in .task may continue running and keep your @StateObject alive if it's not cancellable or if you use Task.detached (which isn't tied to the view's lifecycle).

Potentially leaky pattern:

import SwiftUI

final class Poller: ObservableObject {
    @Published var value = 0
    private var task: Task<Void, Never>?
    init() { print("Poller init") }
    deinit { print("Poller deinit") }
    func start() {
        // ❌ Detached task outlives the view unless you cancel manually
        task = Task.detached { [weak self] in
            guard let self else { return }
            while true {
                try? await Task.sleep(nanoseconds: 1_000_000_000)
                await MainActor.run { self.value += 1 }
            }
        }
    }
    func stop() { task?.cancel(); task = nil }
}
struct PollingView: View {
    @StateObject private var poller = Poller()
    var body: some View {
        Text("Value: \(poller.value)")
            .task { poller.start() }          // ❌ Detached; not auto-cancelled
            .onDisappear { poller.stop() }    // If you forget this, leak-like behavior
    }
}

Fixed (structured & cooperative):

final class SafePoller: ObservableObject {
    @Published var value = 0
    init() { print("SafePoller init") }
    deinit { print("SafePoller deinit") }

    func run() async {
        while !Task.isCancelled {             // ✅ Cooperative cancellation
            try? await Task.sleep(nanoseconds: 1_000_000_000)
            await MainActor.run { self.value += 1 }
          }
      }
    }
    struct SafePollingView: View {
      @StateObject private var poller = SafePoller()
      var body: some View {
        Text("Value: \(poller.value)")
            .task {                           // ✅ Tied to the view's lifetime
                await poller.run()
            }
    }
}

Guidelines: Prefer structured concurrency (.task { await ... }) for view-scoped work. If you must use Task.detached, store and cancel it explicitly and check Task.isCancelled.

4) Your Detective Kit: How to Hunt Down Leaks

Tool 1: The First Clue — deinit Print Statement

Your fastest, cheapest signal. Add it liberally during development:

final class MyViewModel {
    init() { print("MyViewModel init") }
    deinit { print("MyViewModel deinit") }   // Expect this after dismissal
}

If you don't see deinit when expected, you likely have a leak or lifetime extension.

Tool 2: Visualizing the Crime Scene — Xcode Memory Graph Debugger

  1. Run your app and navigate to the suspect screen.
  2. Navigate away (dismiss/pop).
  3. Click Debug Memory Graph (small graph icon) in Xcode's debug bar.
  4. In the left navigator, search for your class (e.g., MyViewController, TickerViewModel).
  5. If instances are still alive, select one to open the graph.
  6. Inspect relationships:
  • Solid arrows = strong references.
  • Cycles often appear as A → B → A paths.
  • Newer Xcodes may highlight suspicious paths with a purple ! icon.

Follow strong edges until you find the culprit: a strong delegate, a stored escaping closure, or a Combine sink capturing self.

Tool 3: Tracking the Footprints — Instruments "Allocations"

Great for measuring retention across interactions — even without a classic cycle.

Mark Generation technique:

  1. Profile (⌘I) → choose Allocations.
  2. Before presenting the suspect screen, click Mark Generation.
  3. Use the screen normally.
  4. Navigate away to a neutral state.
  5. Click Mark Generation again.
  6. Inspect the Growth column per generation.
  • If your custom objects (VCs, ViewModels, services) from Gen 1 are still alive after stepping back, you've got a leak (or at least unintended retention).

5) Fixes & Patterns You Can Rely On

  • Delegates: Always weak and class-constrain the protocol (protocol P: AnyObject {}).
  • Escaping closures: Default to [weak self] when touching self. Use guard let self = self else { return }.
  • SwiftUI value-first design: Prefer struct models for transient state. Keep class-based models (ObservableObject) focused and minimal.
  • Combine hygiene:
  • Capture [weak self] in sink when storing in self.
  • Prefer operator chains that avoid capturing self (e.g., assign(to: &$prop)).
  • Avoid self-owning cycles (self → cancellables → sink(closure capturing self) → self).
  • Async work: Favor structured concurrency; check Task.isCancelled. Avoid Task.detached for view-scoped jobs, or cancel it explicitly.
  • Tidy up: Clear long-lived references after use (e.g., service.completion = nil).
  • Make deinit a habit and profile regularly (Memory Graph + Allocations).

6) UIKit & SwiftUI: Quick Debug Checklists

UIKit

  • All delegates/data sources: weak + : AnyObject.
  • Closures in services: [weak self] and clear after firing.
  • Remove observers or use token-based APIs that auto-clean (NotificationCenter tokens).
  • Break possible cycles in custom containers (child VCs, coordinators).

SwiftUI

  • ObservableObject sinks don't strongly capture self.
  • Use assign(to: &$state) where possible.
  • .task work is cancellable and not detached (or cancelled explicitly).
  • Prefer value types for ephemeral state; keep reference types lean.

7) Conclusion & Golden Rules for Prevention

Leaks are simple in cause — strong reference cycles — but sneaky in practice. They show up in UIKit, SwiftUI, and especially where the two meet.

Golden Rules

  • Use weak for all delegate properties (protocol … : AnyObject).
  • Use [weak self] in escaping closures that reference self.
  • Prefer value types (struct) where possible, especially in SwiftUI.
  • Manage Combine cancellables carefully, and avoid self-capturing sink cycles.
  • Make deinit your best friend during development.
  • Profile often with Memory Graph and Instruments (Allocations with Mark Generation).

Master these concepts and tools, and you'll ship apps that are not only feature-rich, but also robust, performant, and reliable — no silent killers lurking in the heap.