Retain Cycles
When two objects keep each other alive and ARC can never free either.
A retain cycle is a loop of strong references. A holds B strongly, B holds A strongly, and each keeps the other's count at one. Every outside reference can disappear and both counts still sit above zero, so ARC never frees either. Nothing is reachable and nothing is released.
That's a leak, and on iOS it's usually invisible: no crash, no error, just memory that grows a bit every time the user visits a screen. You find it when the app is killed for memory pressure after twenty minutes of use, or you don't find it at all.
Cycles need at least two participants, but they don't need exactly two. Three-object loops and closure-shaped loops are just as common.
final class Parent { var child: Child? deinit { print("Parent gone") }}
final class Child { var parent: Parent? // strong both ways → cycle deinit { print("Child gone") }}
do { let p = Parent(); let c = Child() p.child = c c.parent = p}// prints nothing. Both leaked.
// The fix: one side stops insistingfinal class FixedChild { weak var parent: Parent? // child does not own its parent deinit { print("Child gone") }}
Show all 22 linesShow less
final class Parent { var child: Child? deinit { print("Parent gone") }}
final class Child { var parent: Parent? // strong both ways → cycle deinit { print("Child gone") }}
do { let p = Parent(); let c = Child() p.child = c c.parent = p}// prints nothing. Both leaked.
// The fix: one side stops insistingfinal class FixedChild { weak var parent: Parent? // child does not own its parent deinit { print("Child gone") }}A Parent object holds a strong reference to a Child, and the Child holds a strong reference back to the Parent. With the external reference gone, both counts remain at one and neither is freed.
Both objects reference each other strongly.
- Both objects reference each other strongly.
- The last outside reference is released.
- Each still holds the other at a count of one. Nothing reaches zero, so nothing is freed.
The four shapes
Where they actually come from
In practice almost every cycle in an iOS codebase is one of four shapes:
- A child holding its parent strongly — the example above. Fix:
weak var parent. - A strong delegate — the delegating object holds its delegate, which is usually its owner. Fix:
weak var delegate, which is why the convention exists. - A closure stored on
selfthat capturesself— the object owns the closure, the closure owns the object. Fix:[weak self]in the capture list. - A long-lived observer or subscription that captures
self— a CombineAnyCancellable, a timer target, a notification block. The subscription outlives the screen and drags it along.
Recognising the shape is faster than reasoning from first principles each time.
Quick check
Two objects reference each other strongly and every outside reference is dropped. What does ARC do?
- Detects the objects are unreachable and frees them
- Frees them at the next memory warning
- Nothing — both counts remain above zero, so both leak
- Raises a runtime warning about the cycle
Reveal answer
Nothing — both counts remain above zero, so both leak
ARC only ever looks at one object's count. It has no notion of reachability, no roots to trace from, and no background pass — those are tracing-GC concepts. Each object here still has one strong reference (from the other), so neither count reaches zero and neither is ever freed. Memory pressure doesn't help; the objects are perfectly valid, just unreachable.
Interview question
Explain a retain cycle, and how you'd detect one in a production application.
Think first, then reveal
A retain cycle is a loop of strong references. Because ARC frees an object only when its own count hits zero, and every object in the loop is being retained by another member of the loop, the whole group stays alive after it becomes unreachable. It's a leak with no crash and no error.
For detection I'd work in three layers.
During development, a deinit log on view controllers and view models. Push a screen, pop it, watch for the line. It costs nothing and catches the majority of cycles the day they're introduced, which is when they're cheapest to fix.
When something's suspected, Xcode's memory graph debugger. It renders the actual object graph and marks cycles directly, so you see which reference to weaken rather than guessing. Instruments' Allocations with a repeated push/pop is the companion — if the persistent-object count for a type climbs by one per visit and never comes down, that's your leak, and the generation summary tells you what's accumulating.
In production, you don't see individual cycles — you see the aggregate. Memory-related terminations in MetricKit, and jetsam events in the organizer, tell you memory is the problem and which build regressed. Then you reproduce with the tools above. It's also worth logging peak footprint per session so a regression shows up as a trend rather than as user complaints.
Prevention beats detection though: ownership pointing one way, weak delegates, [weak self] in stored closures, and reviewing subscriptions for what they capture.
What an interviewer listens for
- Correctly explains why counting can't detect a cycle
- Gives a layered answer: dev-time, debugging, production telemetry
- Names concrete tools — memory graph debugger, Allocations, MetricKit
- Mentions the deinit-log technique as the cheap first line
- Ends on prevention, not just detection
Key takeaway
A cycle is a loop of strong references that ARC can't see out of. Decide which object owns which, make ownership point one way, and weaken the reference pointing back.