What is a retain cycle, and how do you break one?
TL;DR
A retain cycle is when two or more objects hold strong references to each other. Their reference counts never reach zero. They are never freed. This is a memory leak.
Break it by making one link weak or unowned.
Common sources: closures that capture self, delegates, parent-child links, timers and notification observers.
The simplest cycle
final class Person {
var apartment: Apartment?
deinit { print("Person freed") }
}
final class Apartment {
var tenant: Person? // strong
deinit { print("Apartment freed") }
}
var p: Person? = Person()
var a: Apartment? = Apartment()
p?.apartment = a
a?.tenant = p
p = nil
a = nil
// Nothing is printed. Both objects leak.Each object keeps the other one alive.
The fix
final class Apartment {
weak var tenant: Person? // weak breaks the cycle
}Now when p = nil, the person is freed. Then the apartment is freed too.
Cycle 1: closures capturing self
The most common cycle in real apps.
final class ProfileViewModel {
var name = ""
var onChange: (() -> Void)?
init() {
onChange = {
print(self.name) // closure holds self strongly
} // self holds closure strongly
}
}Fix with a capture list:
onChange = { [weak self] in
guard let self else { return }
print(self.name)
}Cycle 2: strong delegates
protocol PlayerDelegate: AnyObject { func didFinish() }
final class Player {
var delegate: PlayerDelegate? // bad: strong
}
final class PlayerViewController: UIViewController, PlayerDelegate {
let player = Player()
override func viewDidLoad() {
super.viewDidLoad()
player.delegate = self // VC -> player -> VC
}
func didFinish() { }
}Fix: weak var delegate: PlayerDelegate?.
Cycle 3: Timer
Timer keeps a strong reference to its target or block. The run loop keeps the timer.
final class Clock {
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
}
func tick() { }
func stop() { timer?.invalidate() } // also needed!
}[weak self] is not enough alone. A repeating timer keeps running until you call invalidate(). A good place is when the screen disappears.
Cycle 4: Combine subscriptions
final class SearchViewModel {
@Published var query = ""
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] text in self?.search(text) }
.store(in: &cancellables) // self -> cancellables -> sink -> self
}
func search(_ text: String) { }
}Without [weak self], the sink closure holds self and self holds the sink.
Cycle 5: long-running Tasks
final class FeedViewModel {
private var task: Task<Void, Never>?
func start(updates: AsyncStream<String>) {
task = Task { [weak self] in
for await item in updates {
self?.handle(item)
}
}
}
func handle(_ item: String) { }
func stop() { task?.cancel() }
}A Task holds what it captures until it ends. A short task is fine. A task that loops forever keeps self alive forever. Use [weak self] and cancel it.
Which link should be weak?
Think about ownership. The owner holds a strong reference. The child points back weakly.
| Relationship | Strong side | Weak side |
|---|---|---|
| View controller and its view | VC owns view | — |
| Parent and child | Parent → child | Child → parent |
| Object and its delegate | Delegate owner → object | Object → delegate |
| Object and stored closure | Object → closure | Closure → [weak self] |
How to detect cycles
- Add
deinit { print("freed") }and check it prints when you leave a screen. - Use Xcode's Debug Memory Graph button. Leaked objects show a purple warning.
- Use the Leaks instrument in Instruments.
- In unit tests, check that a weak reference becomes
nilafter the object should be gone.
Common follow-up questions
- Does
DispatchQueue.main.async { self.x = 1 }cause a cycle? No. The queue does not belong toself. The closure is released after it runs. - Do SwiftUI views cause retain cycles? Views are structs, so they cannot. But your
@Observableclasses and their closures still can. - Are cycles possible with
NotificationCenter? Yes, with the block API. The center keeps the block, and the block may captureself. Remove the observer or use[weak self]. - Can a cycle involve three objects? Yes. A → B → C → A is also a cycle. The memory graph helps find these.
- Is
unowneda valid fix? Yes, if the other object always lives longer. Otherwise useweakto avoid crashes.