Errors, memory and semantics · core

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.

13 min read14 min practice0/2 exercises5 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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:

  1. 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.
  2. deinit runs at a predictable line. That makes it reliable for releasing real resources — closing a file, ending a database transaction, invalidating a timer.
  3. 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 nil automatically 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.

RelationshipForwardBack
Parent model → child modelsstrongweak var parent
Screen → its view modelstrong
Object → its delegateweak var delegate
List → itemsstrongweak 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:

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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: add deinit { 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.
Check yourself

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

Write the code

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.

Solution unlocks after 3 attempts

A delegate done properly

Write the code

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.

Solution unlocks after 3 attempts
Connect itSaved on this device. Never graded.

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.

Checkpoint

You can now:

  • Trace reference counts through assignments and explain when deinit fires
  • Spot the two cycle shapes: object↔object, and object↔stored-closure
  • Apply the ownership rule: forward strong, back-reference weak
  • Justify weak vs unowned in one sentence, and default to weak

Next up: property wrappers — the machinery behind @State, and how to build your own.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.