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
nilautomatically 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
weakor (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
weakworks: 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 selfAlso ensure: Any explicit callback properties on your
ObservableObjectuse[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 useTask.detached, store and cancel it explicitly and checkTask.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
- Run your app and navigate to the suspect screen.
- Navigate away (dismiss/pop).
- Click Debug Memory Graph (small graph icon) in Xcode's debug bar.
- In the left navigator, search for your class (e.g.,
MyViewController,TickerViewModel). - If instances are still alive, select one to open the graph.
- 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:
- Profile (⌘I) → choose Allocations.
- Before presenting the suspect screen, click Mark Generation.
- Use the screen normally.
- Navigate away to a neutral state.
- Click Mark Generation again.
- 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
weakand class-constrain the protocol (protocol P: AnyObject {}). - Escaping closures: Default to
[weak self]when touchingself. Useguard let self = self else { return }. - SwiftUI value-first design: Prefer
structmodels for transient state. Keepclass-based models (ObservableObject) focused and minimal. - Combine hygiene:
- Capture
[weak self]insinkwhen storing inself. - 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. AvoidTask.detachedfor view-scoped jobs, or cancel it explicitly. - Tidy up: Clear long-lived references after use (e.g.,
service.completion = nil). - Make
deinita 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 (
NotificationCentertokens). - Break possible cycles in custom containers (child VCs, coordinators).
SwiftUI
ObservableObjectsinks don't strongly captureself.- Use
assign(to: &$state)where possible. .taskwork 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
weakfor all delegate properties (protocol … : AnyObject). - Use
[weak self]in escaping closures that referenceself. - Prefer value types (
struct) where possible, especially in SwiftUI. - Manage Combine cancellables carefully, and avoid self-capturing
sinkcycles. - Make
deinityour 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.