1.10 — Memory management: ARC, retain cycles, weak/unowned
Opening scenario
Your app’s leak chart in Instruments looks like a staircase going up. Every time the user opens a detail screen, memory rises by ~3 MB and never comes back down. After ten navigations the app gets jettisoned by the OS for using too much RAM.
You crack open the view controller:
class DetailViewController: UIViewController {
var viewModel: DetailViewModel?
override func viewDidLoad() {
super.viewDidLoad()
viewModel?.onUpdate = { user in
self.userLabel.text = user.name // 🔥 retain cycle
}
}
}
By the end of this chapter you’ll spot that bug in under a second and know the three ways to fix it.
How Swift manages memory
Swift uses ARC — Automatic Reference Counting. The compiler inserts retain and release calls around every reference assignment. When the retain count drops to zero, the object is deallocated. This happens deterministically, at the moment the last reference goes away — no garbage collector, no GC pauses, no nondeterministic finalizers.
class Engine {
init() { print("Engine init") }
deinit { print("Engine deinit") }
}
func demo() {
let e1 = Engine() // count = 1, prints "Engine init"
let e2 = e1 // count = 2
_ = e2 // keep e2 alive
} // both refs out of scope → count = 0 → "Engine deinit"
Value types (structs, enums) are not reference-counted. They live on the stack or inline in their owner. ARC only matters for classes and class-based types (closures count as reference types too).
The three reference flavors
| Flavor | Increments count? | Becomes nil when target deallocated? | Use when |
|---|---|---|---|
strong (default) | Yes | N/A (it owns) | The reference owns the lifetime |
weak | No | Yes — becomes nil automatically | Reference doesn’t own; target may outlive it |
unowned | No | No — accessing after dealloc crashes | Like weak but you guarantee the target outlives this ref |
class Person {
let name: String
weak var apartment: Apartment? // doesn't keep the apartment alive
init(name: String) { self.name = name }
}
class Apartment {
let unit: String
var tenant: Person? // owns tenant
init(unit: String) { self.unit = unit }
}
weak references must be var Optional<T> — they have to be able to become nil. unowned is non-optional but unsafe if you misjudge the lifetime.
The retain cycle problem
Two objects holding strong references to each other never reach zero. ARC can’t break the cycle. The classic case is parent ↔ child with both sides strong:
class Parent { var child: Child? }
class Child { var parent: Parent? } // 🔥 strong both ways
var p: Parent? = Parent()
var c: Child? = Child()
p?.child = c
c?.parent = p
p = nil // count goes from 2 to 1 (c still references it)
c = nil // count goes from 2 to 1 (p still references c)
// Both leak forever.
Fix: make one direction weak (typically the back-reference from child to parent):
class Child { weak var parent: Parent? }
Closures: the modern source of cycles
Closures capture references. A closure stored on self that mentions self creates a cycle:
class Loader {
var onDone: (() -> Void)?
var name = "loader"
func start() {
onDone = {
print(self.name) // 🔥 closure retains self, self retains closure
}
}
}
The fix is the capture list [weak self] or [unowned self]:
func start() {
onDone = { [weak self] in
guard let self else { return }
print(self.name)
}
}
The capture list runs once, when the closure is created. [weak self] captures self as a weak reference; inside the closure you unwrap it with guard let self.
Use [unowned self] only when you’re certain self outlives the closure (typically: the closure is owned by self and runs synchronously). For async closures, network callbacks, observers — always [weak self]. The wrong choice crashes; [weak self] is the safe default.
When NOT to worry
- Pure value-type code. Structs and enums copy, no ARC.
@MainActorObservableObject view models that don’t hold completion-handler closures internally. SwiftUI’s@StateObjectand@ObservedObjectuse weak-ish semantics under the hood.async/awaitcode. No closure captures — the compiler manages task lifetimes via the structured concurrency model. This is one of the underappreciated wins ofasync/awaitover callbacks: a whole category of retain cycles simply disappears.
In the wild
- SwiftUI views are structs — no ARC at the view level. The retain-cycle worry has shifted to view models and Combine pipelines.
- Combine
sinkclosures are the most common source of cycles in modern code.cancellable.sink { [weak self] in … }is the idiom. NotificationCenter.addObserver(forName:object:queue:using:)— the block-based variant retains its observer block.[weak self]is mandatory here.- Older callback APIs (Firebase, Alamofire pre-async) all need
[weak self]discipline.
Common misconceptions
-
“Swift has garbage collection.” No. ARC is deterministic reference counting, inserted at compile time. No GC pauses, no nondeterminism.
-
“You should put
[weak self]in every closure.” Overkill. Closures that don’t escape (i.e., that run synchronously, likearray.map { … }) don’t create cycles. Capture lists are for escaping closures stored onselfor passed toasyncwork. -
“
unownedis faster thanweak.” Marginally —unownedskips the optional unwrap. Not worth the crash risk in 99% of cases. Reach forunownedonly when the relationship is structurally guaranteed. -
“
weak selfworks for value types too.” No.weakandunownedapply only to class references. Value types are copied, not referenced. -
“Instruments leaks tool catches all leaks.” It catches unreachable retained memory (classic leaks). It doesn’t catch abandoned memory — long-lived caches that grow forever. Use the Allocations instrument for the latter, and watch the steady-state baseline grow.
Seasoned engineer’s take
ARC is one of the things Swift got enormously right. Predictable destruction, no GC pauses, low overhead — for a mobile platform with tight memory budgets, it’s the right model. But it requires discipline:
- Default to value types, and the whole conversation collapses to “no ARC.”
- For classes, draw the ownership tree on a whiteboard. Who owns whom? The back-edges (child → parent, observer → subject) are always
weak. - Always
[weak self]in escaping closures, unless you have a specific reason for[unowned self]. The cost of[weak self] + guard let selfis one extra line; the cost of a retain cycle is a memory leak shipped to production. - Profile with Instruments at least once per release cycle. The Allocations + Leaks combo will flag drift you didn’t see in code review.
Specific traps I’ve seen ship to production at multiple companies:
- A
URLSessionTaskstored onselfthat capturesselfin its completion handler — fix with[weak self]. - A timer (
Timer.scheduledTimer(...)) that capturesselfin its block — fix with[weak self], and invalidate the timer indeinit. - A Combine pipeline
vm.$query.sink { self.search($0) }— fix with[weak self]. - A coordinator pattern where the coordinator strongly retains every child VC and never releases them — fix the navigation lifecycle, not the references.
TIP: Run Instruments → Allocations with the “Mark Generation” button. Mark before opening a screen, navigate forward and back, mark again, look at the diff. Anything in the diff that should be deallocated and isn’t is a leak.
WARNING: Never put
[unowned self]in an async closure that may run after self is deallocated (network callback, animation completion). It will crash.[weak self]is the only safe choice for asynchronous escapes.
Interview corner
Question: “How does memory management work in Swift, and what’s a retain cycle?”
Junior answer: “Swift uses ARC. A retain cycle is when two objects reference each other and can’t be released.” → Definitionally correct, no depth.
Mid-level answer: “Swift uses Automatic Reference Counting — the compiler inserts retain/release calls, and objects are deallocated when their reference count hits zero. A retain cycle happens when two objects (or an object and a closure) hold strong references to each other, so neither’s count can reach zero. The classic case in modern code is a closure captured on self that mentions self — fix it with [weak self] in the capture list, then guard let self else { return } inside the closure. For parent/child object graphs, the back-edge is always weak.” → Strong, real fix.
Senior answer: Plus: “I’d also talk about prevention. Value types — structs and enums — sidestep ARC entirely, so the more of my model layer I can make value-typed, the smaller my retain-cycle surface area. For class graphs, I draw the ownership tree explicitly: who owns the lifetime, who’s an observer. Back-edges and observers are weak. I’d mention that async/await has dramatically reduced retain-cycle bugs because the compiler manages task lifetimes — there’s no closure capture for me to forget [weak self] on. And in code review I’d flag any escaping closure stored on self that mentions self without a capture list. As for unowned vs weak, I default to weak because the cost of guard let self is trivial and the cost of a wrong unowned is a crash.” → Senior signal: prevention thinking, modern-tool awareness.
Red-flag answer: “I just add [unowned self] to every closure so I don’t have to deal with optionals.” → Will ship crashes.
Phase 1 wrap-up
You now have the language. You know:
- The history and where Swift sits today (1.1)
- Where Swift code lives — playgrounds, scripts, SPM, Xcode projects (1.2)
- Types, optionals, the five unwrap forms (1.3)
- Control flow, functions, closures (1.4)
- Collections and higher-order functions (1.5)
- Structs vs classes vs enums vs protocols (1.6)
- Generics,
some,any(1.7) - Error handling —
throws,try,Result(1.8) - Concurrency — async/await, Tasks, actors, Sendable (1.9)
- ARC, weak/unowned, retain cycles (1.10)
You also have four labs to prove you’ve learned it:
- 1.A — Playground exploration
- 1.B — Command-line tool with SwiftPM
- 1.C — Protocol-oriented calculator
- 1.D — Async image fetcher
When you can hold a conversation about every chapter above and you’ve shipped the four labs, you’ve cleared Phase 1. Next: Phase 2 — where Swift meets the platform: UIKit, SwiftUI, and the iOS app lifecycle.
Next: head to the labs to apply what you’ve learned. → Lab 1.A