ARC, retain cycles and weak references
How Swift frees memory without a garbage collector, the one situation where it needs your help, and the [weak self] habit that prevents it.
By the end you will be able to
- Explain how reference counting works and why deinit timing is predictable
- Recognise the three places retain cycles actually happen in real apps
- Choose correctly between strong, weak and unowned
Swift frees a class instance's memory at a specific moment. Which moment?
How ARC works
Every class instance has a reference count. Assigning it to a variable, a property, or capturing it in a closure adds one. A reference going away — variable reassigned, property cleared, object holding it deallocated — subtracts one. At zero, the instance is destroyed immediately.
final class Session {
let id: String
init(id: String) {
self.id = id
print("session \(id) created")
}
deinit {
print("session \(id) destroyed")
}
}
var a: Session? = Session(id: "A") // count: 1
var b = a // count: 2
a = nil // count: 1 — still alive
b = nil // count: 0 — "session A destroyed" prints HERE
Three things to internalise:
- Only classes are counted. Structs and enums are copied, not shared, so there is nothing to count. This is yet another reason Swift pushes you toward value types: most of your data never touches this machinery at all.
deinitruns at a predictable line. That makes it reliable for releasing real resources — closing a file, ending a database transaction, invalidating a timer.- The compiler inserts the counting. The "A" in ARC is automatic: you never write retain/release. You only get involved when the counting has a blind spot.
The blind spot: reference cycles
Counting fails in exactly one shape: two objects that hold strong references to each other.
final class Person {
let name: String
var apartment: Apartment?
init(name: String) { self.name = name }
deinit { print("\(name) deallocated") }
}
final class Apartment {
let unit: String
var tenant: Person? // ← strong reference back
init(unit: String) { self.unit = unit }
deinit { print("\(unit) deallocated") }
}
var ada: Person? = Person(name: "Ada")
var flat: Apartment? = Apartment(unit: "4A")
ada?.apartment = flat
flat?.tenant = ada
ada = nil // Person's count: your variable gone, but Apartment still holds one → 1
flat = nil // Apartment's count: same story → 1
// Neither deinit ever prints. Both objects are unreachable AND alive: a leak.
Each object keeps the other's count above zero. Your app can no longer reach either of them, but ARC only counts — it does not chase reachability the way a garbage collector does. The pair floats in memory until the app dies.
One leaked pair is invisible. The production version is a screen the user opens two hundred times: each visit leaks a view model, each view model holds images, and forty minutes into a session the app is killed for memory. That termination shows up in your crash reports as EXC_RESOURCE or a jetsam event — with no stack trace pointing at the cycle, which is what makes these miserable to find after the fact and worth preventing by habit.
weak — a reference that doesn't count
Marking a reference weak means: point at this object, but don't keep it alive.
final class Apartment {
let unit: String
weak var tenant: Person? // ← does not contribute to Person's count
init(unit: String) { self.unit = unit }
}
Now ada = nil really does drop the person's count to zero — the apartment's back-reference never counted. And because the person can now vanish while the apartment still exists, Swift enforces two things about every weak reference:
- it must be optional — the object it points at may be gone, and
- it is set to
nilautomatically at that moment, so you can never touch a freed object through it.
The rule of thumb that resolves almost every case:
Ownership points one way. The owner holds the owned strongly; every back-reference is weak.
| Relationship | Forward | Back |
|---|---|---|
| Parent model → child models | strong | weak var parent |
| Screen → its view model | strong | — |
| Object → its delegate | — | weak var delegate |
| List → items | strong | weak var list |
The delegate row is the one you will type most often. A delegate is always a back-reference — the button does not own the screen that listens to it — so delegates are weak by convention across nearly every Apple framework, and yours should be too. (Interview-worthy exceptions: URLSession holds its delegate strongly until you invalidate the session, and CAAnimation retains its delegate.)
Closures capture strongly — and that's the cycle you'll actually write
Object-to-object cycles are easy to spot once you know the shape. The one that ships to production looks different:
final class ProfileViewModel {
var username = ""
var onUpdate: (() -> Void)?
func startObserving() {
onUpdate = {
self.render() // ← closure captures self strongly
}
}
func render() { print("rendering \(username)") }
}
Follow the arrows: the view model stores onUpdate (strong), and onUpdate captured self (strong). The object holds a closure that holds the object. Same cycle, one participant — and it hides in completion handlers, timer callbacks, notification observers, and every stored closure that mentions self or even touches a property (which is an implicit self.).
First, convince yourself that closures really do keep their captures alive:
The closure below captures the Score instance. What do the two log() calls print?
final class Score {
var points = 0
}
func makeLogger(for score: Score) -> () -> Void {
return { print("score is \(score.points)") }
}
var current: Score? = Score()
let log = makeLogger(for: current!)
current?.points = 10
log()
current = nil
log()The fix: [weak self]
A capture list changes how the closure captures:
func startObserving() {
onUpdate = { [weak self] in
guard let self else { return }
self.render()
}
}
[weak self] captures self weakly, so the closure no longer keeps the view model alive. Inside, self is optional — the object may have been deallocated before the closure runs — and guard let self handles that once, giving the rest of the body a normal non-optional self.
The guard let self else { return } line embodies a real design decision: if the screen is gone, do nothing. For UI callbacks that is nearly always right — there is nothing left to render.
unowned — weak without the optional
unowned also breaks cycles, but the reference stays non-optional and is not nilled on deallocation. Touch it after the object is gone and the app traps — the memory-safety equivalent of force-unwrap.
Use it only when the referencing object provably cannot outlive the referenced one — the textbook case is CreditCard.customer, where a card cannot exist without its customer and dies with them. If you cannot state that lifetime guarantee in one sentence, use weak. The cost of being wrong is a crash in the field, and the optional you saved was one guard let.
Finding leaks in practice
Two tools, both under-used:
- Xcode's Memory Graph (the three-circles icon in the debug bar): pauses the app and draws every object and every reference. Leaked cycles get a purple badge, and clicking one shows you the exact retain path — usually straight to the closure that captured
self. deinit+ print during development: adddeinit { print("💀 \(Self.self)") }to your view models, then navigate in and out of the screen. No print when you leave = something is holding it. This ten-second check catches most cycles before they ever reach a device.
A view model stores onTick, a closure its timer fires every second, and the closure reads self.count. What is the correct capture?
Break the cycle
Downloader stores a completion handler that captures self strongly — in a real app this pair leaks. Rewrite start() to capture self weakly, keeping the output identical.
A delegate done properly
Wire up the classic delegate pattern: Oven notifies its delegate when the timer finishes. Follow the convention — the delegate reference must not keep the kitchen alive.
Think of an app you use that clearly holds long-lived observations — a chat app watching for messages, a fitness app watching a workout. Describe one plausible retain cycle in its architecture (which object, which closure, which stored reference), and how [weak self] changes what happens when the user closes that screen mid-operation.
You can now:
- Trace reference counts through assignments and explain when
deinitfires - Spot the two cycle shapes: object↔object, and object↔stored-closure
- Apply the ownership rule: forward strong, back-reference
weak - Justify
weakvsunownedin one sentence, and default toweak
Next up: property wrappers — the machinery behind @State, and how to build your own.