Diagnosing ARC Retain Cycles with Blocks in Objective-C View Controllers
View controllers that never deallocate after dismissal usually mean a retain cycle with a block. This guide maps symptoms to causes and gives ordered checks and fixes for ARC in Objective-C.
06 May 2026, 18:20 UTC

The problem you can see
A view controller should deallocate after dismissal or pop. When it does not, dealloc is never called, memory grows on repeated present-dismiss cycles, and the same instance remains visible in Xcode Memory Graph Debugger or Instruments Allocations. The most common ARC root cause with blocks is a strong reference cycle: self owns a block, and the block captures self strongly.
Recognizable condition
- dealloc is not reached after dismissViewControllerAnimated or popViewControllerAnimated
- Heap growth per cycle in Allocations with Mark Generation
- Persistent instance of the view controller in Memory Graph Debugger after the flow completes
Cause diagnostic table
| Symptom pattern | Likely root | Why it retains |
|---|---|---|
| View controller lives after dismissal, block stored in a strong property of self | Strong self capture in block | self -> block property -> block captures self strongly |
| Delegate callback never stops, object lives | Delegate declared strong | Parent -> child strong, child -> parent strong via delegate |
| Timer fires after view is gone, view controller stays alive | NSTimer or dispatch source not invalidated | Timer retains target; target retains timer |
| KVO changes still delivered after deallocation attempt | Observer not removed | Observed object retains observer registration |
Ordered checks
Confirm non-deallocation. Add a log or breakpoint in dealloc in the view controller source. Present and dismiss the controller. Expected check: dealloc fires once per presentation. Risk: logging in dealloc can be misleading if the object is moved; use a breakpoint for certainty.
Find living instances. In Xcode, run the app and reproduce present-dismiss 3 times. Open Debug Memory Graph and filter for the view controller class name. Expected check: only zero or one live instance remains. Use Instruments Allocations with Mark Generation between cycles to compare heap growth.
Inspect block captures. In source, locate blocks assigned to properties of self, e.g., completion blocks, network callbacks. In debugger, use `po [self valueForKey:@"_completionBlock"]` to inspect the block. Check source for `self.` usage inside the block literal. Strong capture occurs by default under ARC.
Audit property declarations. Search the view controller header for `delegate` properties. Verify they are `weak` not `strong` or `retain`. Check callback properties that hold blocks: they are usually `copy`.
Verify timers and KVO lifecycle. Search for `scheduledTimerWithTimeInterval`, `dispatch_source_create`, `addObserver:forKeyPath:`. Confirm invalidation or removal in `viewWillDisappear` or `dealloc`. Note: relying on dealloc for cleanup hides the leak.
Fixes tied to findings
Strong self capture in a stored block
Break the cycle with a weak-strong dance. The weak reference prevents the block from keeping self alive; the strong re-capture inside the block guards against self becoming nil mid-execution.
// before
self.completionBlock = ^{
[self updateUI];
};// after
__weak typeof(self) weakSelf = self;
self.completionBlock = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf updateUI];
};Limitation: weakSelf can become nil during execution, so guard with strongSelf. Do not use __unsafe_unretained for this purpose; it is non-zeroing and can dangle.
Delegate property strength
Change delegate to weak.
@property (nonatomic, weak) id<MyDelegate> delegate;Required permission: none. Risk: if the delegate is deallocated early, messages are silently dropped.
Timer and dispatch source
Invalidate and nil in the view lifecycle.
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self.timer invalidate];
self.timer = nil;
}For dispatch sources, call `dispatch_source_cancel` and release the reference.
KVO removal
Remove each observer added.
- (void)dealloc {
[observed removeObserver:self forKeyPath:@"value"];
}Limitation: dealloc may never run if a cycle exists, so also remove in viewWillDisappear when possible.
Stored completion blocks
Nil the block property after use to break the reference from self to block.
self.completionBlock = nil;Block copy semantics matter: blocks created on the stack must be copied to the heap before storage. ARC usually handles this for properties declared copy, but manual storage requires explicit copy.
Verification
- dealloc breakpoint fires after dismissal
- Memory Graph shows no remaining view controller instances after flow
- Allocations Mark Generation shows stable instance count across repeated present-dismiss cycles
Escalation criteria
- Cycle persists after applying weak captures, weak delegate, timer invalidation, and KVO removal
- Leak is tied to a third-party SDK that retains blocks opaquely and provides no break point
- Reproduction is limited to specific OS versions or architectures where zeroing weak behavior differs
- Multiple objects form a graph cycle that cannot be broken locally without architectural change
Escalate for architectural review when the retain graph involves several owners, e.g., coordinator -> view controller -> service -> completion block -> coordinator. In those cases, document the graph from Memory Graph Debugger and propose ownership changes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.