Diagnosing Retain Cycles in Objective-C: A Practical Debugging Guide
A step-by-step diagnostic guide for finding and fixing retain cycles in Objective-C: recognize the missing dealloc signal, use Memory Graph and Leaks instruments, apply the weak delegate and weak-self block patterns, and verify the fix with repeatable checks.
26 Jul 2026, 06:51 UTC

The Problem: Objects That Never Release
You dismiss a view controller, pop a navigation stack, or tear down a module—but memory usage doesn't drop. The dealloc method never fires. Instruments shows allocations piling up. This is the classic symptom of a retain cycle: two or more objects holding strong references to each other, keeping reference counts above zero indefinitely. Automatic Reference Counting (ARC) handles the bookkeeping, but it cannot break cycles you created.
Takeaway: If dealloc doesn't run when you expect it to, start hunting for a strong reference loop. The fix is almost always converting one side of the loop to a weak reference.
Recognizable Condition
| Symptom | What It Usually Means |
|---|---|
dealloc not called on dismissal/pop | Object retained by something it also retains (directly or transitively) |
| Memory Graph shows bold arrows between objects | Circular strong references preventing deallocation |
| Leaks instrument flags growing allocations during repeated navigation | Each cycle leaves an abandoned object graph |
Blocks/closures capture self and outlive the creator | Async callbacks, timers, or notification observers holding the creator alive |
Cause and Diagnostic Quick-Reference
| Pattern | Typical Location | Why It Leaks | First Check |
|---|---|---|---|
Delegate property not weak | View controllers, custom views, managers | Parent retains child; child's delegate points back to parent | Inspect @property declaration for missing weak |
Block captures self strongly | Network callbacks, animation completions, GCD dispatch | Block retains self; self retains block (via property or target) | Search for ^{ [self ...] } without __weak |
| Notification observer not removed | viewDidLoad/init adding observers | Notification center retains observer; observer retains center indirectly | Verify removeObserver: in dealloc or viewDidDisappear |
| Timer or CADisplayLink retained by target | Animations, periodic updates | Timer retains target; target retains timer property | Check invalidate called before release |
| Parent-child view controller cycle | Container view controllers | addChildViewController: + child's parentViewController both strong | Confirm removeFromParentViewController called |
Ordered Diagnostic Checks
1. Add a dealloc Log Statement
Insert a single log line in the suspect class. This is the fastest sanity check.
- (void)dealloc {
NSLog(@"%@ dealloc", self.class);
}Where: In the .m file of the class you expect to be released. Expected: Log appears in console when the object should die. Risk: None—read-only diagnostic. If the log never prints, the object is leaked.
2. Open the Memory Graph Debugger
Run the app in Xcode (⌘R). Navigate to the leaking screen, trigger the dismiss/pop action, then click the Debug Memory Graph button (three overlapping circles) in the debug bar.
What to look for:
- Your class instance still listed in the left panel after dismissal
- Bold arrows connecting two objects—these are strong reference cycles
- Click an object; the right panel shows Referencing Objects. Trace the chain back to a root (usually a view controller or singleton)
Required: Xcode 8+. Permission: Debug session on simulator or device.
3. Run the Leaks Instrument
Profile the app (⌘I) → choose Leaks. Exercise the suspect flow repeatedly (push/pop, present/dismiss 5–10 times).
Expected: Red spikes in the Leaks track. Click a spike → Call Tree → enable Invert Call Tree and Hide System Libraries. The top frames usually point to the allocating method.
Limitation: Leaks instrument only catches memory with no remaining references. Retain cycles often show as abandoned memory (still referenced but unreachable from roots) rather than leaks. Use both tools.
4. Static Analysis (Shift+⌘+B)
Xcode's analyzer flags obvious missing weak on delegates and self captures in blocks. It won't catch runtime cycles involving multiple objects, but it catches the low-hanging fruit.
Fixes Tied to Findings
Delegate Property → weak
// Before (leaks)
@property (nonatomic, strong) id delegate;
// After
@property (nonatomic, weak) id delegate;Applies when: Memory Graph shows your object → delegate → your object. Verification: Re-run Memory Graph; bold arrow disappears; dealloc log fires.
Block Capturing self → __weak Dance
// Leaking pattern
[self.networkClient fetchWithCompletion:^{
[self updateUI]; // strong capture
}];
// Fixed pattern
__weak typeof(self) weakSelf = self;
[self.networkClient fetchWithCompletion:^{
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
[strongSelf updateUI];
}
}];Why weak not unowned: unowned (via __unsafe_unretained) crashes if the object deallocates before the block runs. weak safely nils out. Use unowned only when you can prove the block cannot outlive self (rare).
Where to run: In the method creating the block. Check: Block executes, dealloc fires later.
Notification Observer → Remove in dealloc
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}Applies when: Leaks instrument shows NSNotificationCenter retaining your object. Note: iOS 9+ auto-removes observers on dealloc if you used block-based API. Target-action observers still need manual removal.
Timer/CADisplayLink → Invalidate Before Release
- (void)stopAnimation {
[self.displayLink invalidate];
self.displayLink = nil;
}
- (void)dealloc {
[self stopAnimation];
}Applies when: Memory Graph shows CADisplayLink → target → your object. Risk: Calling invalidate on already-invalidated timer is safe (no-op).
Parent-Child View Controller → Proper Teardown
- (void)removeChildController:(UIViewController *)child {
[child willMoveToParentViewController:nil];
[child.view removeFromSuperview];
[child removeFromParentViewController];
child.delegate = nil; // if you added a custom delegate
}Applies when: Container VC retains child; child's parentViewController creates cycle. Verification: Both dealloc methods fire.
Escalation Criteria
Escalate to a deeper investigation when:
- Memory Graph shows no bold arrows but
deallocstill doesn't fire → object may be held by a system framework (e.g.,UITextFieldretaining delegate via private ivar). File a radar or search for known framework bugs. - Cycle spans 3+ objects and no single
weakbreaks it cleanly → consider a dedicated coordinator/mediator object that holds weak refs to all participants. - Leak only appears on device (not simulator) → check for
#if TARGET_OS_SIMULATORdivergences or metal/shader resource cycles. - Third-party library code creates the cycle → wrap the library in a thin adapter where you control the reference semantics, or report upstream.
Verification Checklist After Fix
- ✅
dealloclog prints on every expected dismissal - ✅ Memory Graph shows zero instances of the class after teardown
- ✅ Leaks instrument shows no new spikes after 10+ navigation cycles
- ✅ No crashes from
EXC_BAD_ACCESS(would indicate over-releasing orunownedmisuse) - ✅ Static analyzer clean (⌘+Shift+B)
Limitations
This guide covers the 90% case: retain cycles you introduced in your own code. It does not cover:
- Core Foundation toll-free bridging leaks (
CFRetain/CFReleasemismatches) - Metal/GPU resource cycles (require
MTLResourcetracking) - Swift/Objective-C interop cycles (Swift closures capturing
selfpassed to ObjC APIs) - Zombie objects (over-release) — different symptom: crash on access, not silent leak
For those, the same tools apply but the fix patterns differ.
Quick Reference Card
| Suspect | Search For | Fix |
|---|---|---|
| Delegate | @property.*delegate without weak | Add weak |
| Block | ^{.*self\. | Wrap with __weak/strongSelf |
| Notification | addObserver: without matching removeObserver: | Remove in dealloc |
| Timer | scheduledTimerWithTimeInterval or CADisplayLink | Invalidate + nil property |
| Child VC | addChildViewController: without removeFromParentViewController | Full teardown sequence |
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.