| name | objc-block-retain-cycles |
| description | Use when debugging memory leaks from blocks, blocks assigned to self or properties, network callbacks, or crashes from deallocated objects - systematic weak-strong pattern diagnosis with mandatory diagnostic rules |
| skill_type | discipline |
| version | 1.0.0 |
Objective-C Block Retain Cycles
Overview
Block retain cycles are the #1 cause of Objective-C memory leaks. When a block captures self and is stored on that same object (directly or indirectly through an operation/request), you create a circular reference: self → block → self. Core principle 90% of block memory leaks stem from missing or incorrectly applied weak-strong patterns, not genuine Apple framework bugs.
Red Flags — Suspect Block Retain Cycle
If you see ANY of these, suspect a block retain cycle, not something else:
- Memory grows steadily over time during normal app use
- UIViewController instances not deallocating (verified in Instruments)
- Crash: "Sending message to deallocated instance" from network/async callback
- Network requests or animations prevent view controller from closing
- Weak reference becomes nil unexpectedly in a block
- NSLog, NSAssert, or string formatting hiding self references
- Completion handler fires after the view controller "should be gone"
- ❌ FORBIDDEN Rationalizing as "It's probably normal memory usage"
- Memory leaks are never "normal"
- Apps should return to baseline memory after user dismisses a screen
- Do not rationalize this as "good enough" or "monitor it later"
Critical distinction Block retain cycles accumulate silently. A single cycle might be 100KB, but after 50 screens viewed, you have 5MB of dead memory. MANDATORY: Test on real device (oldest supported model) after fixes, not just simulator.
Mandatory First Steps
ALWAYS run these FIRST (before changing code):
// 1. Identify the leak with Allocations instrument
// In Xcode: Xcode > Open Developer Tool > Instruments
// Choose Allocations template
// Perform an action (open/close a screen with the suspected block)
// Check if memory doesn't return to baseline
// Record: "Memory baseline: X MB, after action: Y MB, still allocated: Z objects"
// 2. Use Memory Debugger to trace the cycle
// Run app, pause at suspected code location
// Debug > Debug Memory Graph
// Search for the view controller that should be deallocated
// Right-click > Show memory graph
// Look for arrows pointing back to self (the cycle)
// Record: "ViewController retained by: [operation/block/property]"
// 3. Check if block is assigned to self or self's properties
// Search for: setBlock:, completion:, handler:, callback:
// Check: Is the block stored in self.property?
// Check: Is the block passed to something that retains it (network operation)?
// Record: "Block assigned to: [property or operation]"
// 4. Search for self references in the block
// Look for: [self method], self.property, self-> access
// Look for HIDDEN self references:
// - NSLog(@"Value: %@", self.property)
// - NSAssert(self.isValid, @"message")
// - Format strings: @"Name: %@", self.name
// Record: "self references found in block: [list]"
// Example output:
// Memory not returning to baseline ✓
// ViewController retained by: AFHTTPRequestOperation
// Operation retains: successBlock
// Block references self: [self updateUI], NSLog with self.property
// → DIAGNOSIS: Block retain cycle confirmed
What this tells you
- Memory stays high → Leak confirmed, not false alarm
- ViewController retained by operation → Block is the culprit
- Block references self → Pattern: weak-strong needed
- Hidden self in NSLog/NSAssert → Need to check ALL macro calls
- No self references found → Maybe not a block cycle, investigate elsewhere
MANDATORY INTERPRETATION
Before changing ANY code, you must confirm ONE of these:
- If memory doesn't return to baseline AND ViewController still allocated → Block retain cycle exists
- If memory returns to baseline → Not a retain cycle, investigate other causes
- If cycle exists but you can't find self references → Check for hidden references (macros, indirect property access)
- If you find the cycle but don't understand the chain → Trace backward through retained objects in Memory Graph
If diagnostics are contradictory or unclear
- STOP. Do NOT proceed to patterns yet
- Add more diagnostics: Print the object graph, list retained objects
- Ask: "If memory is low, why is the ViewController still allocated?"
- Run Instruments > Leaks instrument if memory graph is confusing
Decision Tree
Block memory leak suspected?
├─ Memory stays high after dismiss?
│ ├─ YES
│ │ ├─ ViewController still allocated in Memory Graph?
│ │ │ ├─ YES → Proceed to patterns
│ │ │ └─ NO → Not a block cycle, check other leaks
│ │ └─ NO → Not a leak, normal memory usage
│ │
│ └─ Crash: "Sending message to deallocated instance"?
│ ├─ Happens in block/callback?
│ │ ├─ YES → Block captured weakSelf but it became nil
│ │ │ └─ Apply Pattern 4 (Guard condition is wrong or missing)
│ │ └─ NO → Different crash, not block-related
│ └─ Crash is timing-dependent (only on device)?
│ └─ YES → Weak reference timing issue, apply Pattern 2
│
├─ Block assigned to self or self.property?
│ ├─ YES → Apply Pattern 1 (weak-strong mandatory)
│ ├─ Assigned through network operation/timer/animation?
│ │ └─ YES → Apply Pattern 1 (operation retains block indirectly)
│ └─ Block called immediately (inline execution)?
│ ├─ YES → Optional to use weak-strong (no cycle possible)
│ │ └─ But recommend for consistency with other blocks
│ └─ NO → Block stored or passed to async method → Use Pattern 1
│
├─ Multiple nested blocks?
│ └─ YES → Apply Pattern 3 (must guard ALL nested blocks)
│
├─ Block contains NSAssert, NSLog, or string format with self?
│ └─ YES → Apply Pattern 2 (macro hides self reference)
│
└─ Implemented weak-strong pattern but still leaking?
├─ Check: Is weakSelf used EVERYWHERE?
├─ Check: No direct `self` references mixed in?
├─ Check: Nested blocks also guarded?
└─ Check: No __unsafe_unretained used?
Common Patterns
Pattern Selection Rules (MANDATORY)
Apply ONE pattern at a time, in this order
Always start with Pattern 1 (Weak-Strong Basics)
- If block assigned to self or self's properties → Pattern 1
- If block passed to operation/request that retains it → Pattern 1
- Only proceed to Pattern 2 if pattern still leaks
Then Pattern 2 (Hidden self in Macros)
- Only if memory still leaks after applying Pattern 1
- Check for NSAssert, NSLog, string formatting
- If found, apply Pattern 2
Then Pattern 3 (Nested Blocks)
- Only if block has nested callbacks
- Each nested block needs its own guard
- If found, apply Pattern 3
Then Pattern 4 (Guard Condition Edge Cases)
- Only if crash happens with weakSelf approach
- Check guard condition is correct
- Verify strongSelf used everywhere
FORBIDDEN
- ❌ Applying multiple patterns at once
- ❌ Skipping Pattern 1 because "I already know weak-strong"
- ❌ Using __unsafe_unretained as workaround
- ❌ Using strong self "just this once"
- ❌ Rationalizing: "The block is too small for a leak"
Pattern 1: Weak-Strong Pattern (MANDATORY)
PRINCIPLE Any block that captures self must use weak-strong pattern if block is retained by self (directly or transitively).
❌ WRONG (Creates retain cycle)
[self.networkManager GET:@"url" success:^(id response) {
self.data = response; // self is retained by block
[self updateUI]; // block is retained by operation
} failure:^(NSError *error) {
[self handleError:error]; // CYCLE!
}];
✅ CORRECT (Breaks the cycle)
__weak typeof(self) weakSelf = self;
[self.networkManager GET:@"url" success:^(id response) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
strongSelf.data = response;
[strongSelf updateUI];
}
} failure:^(NSError *error) {
__weak typeof(self) weakSelf2 = self;
typeof(self) strongSelf = weakSelf2;
if (strongSelf) {
[strongSelf handleError:error];
}
}];
Why this works
__weak typeof(self) weakSelf = self;creates a weak reference outside the block- Block captures weakSelf (weak reference), not self (strong reference)
- When block executes, convert to strongSelf (temporary strong ref)
- Check if strongSelf is nil (object was deallocated)
- Use strongSelf for the duration of the block
- strongSelf released when block exits → No cycle
Important details
- Declare weakSelf OUTSIDE the block, not inside
- Use
typeof(self)for type safety (works in both ARC and non-ARC) - Guard condition MUST use
if (strongSelf), not just declare it - Never use direct
selfinside the block once weakSelf is declared - Apply to EVERY block that captures self
- ANY block that captures
selfmust use weak-strong pattern- This includes:
[self method],self.property,self->ivar - Property access (
self.property = value) captures self just like method calls
- This includes:
- Blocks passed to frameworks:
- If framework documentation says 'block is called asynchronously' → Use weak-strong pattern (framework retains the block)
- If framework documentation says 'block is called immediately' → Still safe to use weak-strong (better practice)
- If unsure about framework behavior → Always use weak-strong (doesn't hurt)
Capturing variables (avoiding indirect self references)
// ✅ SAFE: Capture simple values extracted from self
__weak typeof(self) weakSelf = self;
[self.manager fetch:^(id response) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
NSString *name = strongSelf.name; // Extract value
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"Name: %@", name); // Captured the STRING, not self
});
}
}];
// ❌ WRONG: Capture properties directly in nested blocks
__weak typeof(self) weakSelf = self;
[self.manager fetch:^(id response) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"Name: %@", strongSelf.name); // Captures strongSelf again!
});
}
}];
When nesting blocks, extract simple values first, then pass them to the inner block. This avoids creating an indirect capture of self through property access.
Time cost 30 seconds per block
Pattern 2: Hidden self in Macros
PRINCIPLE Macros like NSAssert, NSLog, and string formatting can secretly capture self. You must check them.
❌ WRONG (NSAssert captures self)
[self.button setTapAction:^{
NSAssert(self.isValidState, @"State must be valid"); // self captured!
[self doWork]; // Another self reference
}];
// Leak exists even though you think only [self doWork] captures self
✅ CORRECT (Check for hidden captures)
__weak typeof(self) weakSelf = self;
[self.button setTapAction:^{
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// NSAssert still references self indirectly through strongSelf
NSAssert(strongSelf.isValidState, @"State must be valid");
[strongSelf doWork];
}
}];
Common hidden self references
NSAssert(self.condition, ...)→ Use strongSelf insteadNSLog(@"Value: %@", self.property)→ Use strongSelf.propertyNSError *error = [NSError errorWithDomain:@"MyApp" ...]→ Safe, doesn't capture self- String formatting:
@"Name: %@", self.name→ Use strongSelf.name - Inline conditionals:
self.flag ? @"yes" : @"no"→ Use strongSelf.flag
How to find them
- Search block for all instances of
self. - Mark them:
[self method],self.property,self->ivar - Check if any are inside macro calls (NSAssert, NSLog, etc.)
- Replace with strongSelf
Time cost 1 minute per block to audit
Pattern 3: Nested Blocks (Each Needs Guard)
PRINCIPLE Nested blocks create a chain: outer block captures self, inner block captures outer block variable (which holds strongSelf), creating a new cycle. Each nested block needs its own weak-strong pattern.
❌ WRONG (Guarded outer block only)
__weak typeof(self) weakSelf = self;
[self.manager fetchData:^(NSArray *result) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// Inner block captures strongSelf!
[strongSelf.analytics trackEvent:@"Fetched"
completion:^{
strongSelf.cachedData = result; // Still strong reference!
[strongSelf updateUI];
}];
}
}];
✅ CORRECT (Guard every nested block)
__weak typeof(self) weakSelf = self;
[self.manager fetchData:^(NSArray *result) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// Declare new weak reference for inner block
__weak typeof(strongSelf) weakSelf2 = strongSelf;
[strongSelf.analytics trackEvent:@"Fetched"
completion:^{
typeof(strongSelf) strongSelf2 = weakSelf2;
if (strongSelf2) {
strongSelf2.cachedData = result;
[strongSelf2 updateUI];
}
}];
}
}];
Why this works
- Each nesting level needs its own weakSelf/strongSelf pair
- Outer block: weakSelf → strongSelf
- Inner block: weakSelf2 → strongSelf2
- Each level is independent and safe
Important details
- Don't reuse the same weakSelf variable in nested blocks
- Each nesting level gets a new pair (weakSelf2, strongSelf2)
- Guard condition MANDATORY for each level
- Use consistent naming: weakSelf, weakSelf2, weakSelf3 (for readability)
Common nested block patterns that need Pattern 3
- Completion handlers in callbacks
dispatch_async(queue, ^{ ... })dispatch_after(time, queue, ^{ ... })[NSTimer scheduledTimerWithTimeInterval:... block:^{ ... }][UIView animateWithDuration:... animations:^{ ... }]
Each of these is a block that might capture strongSelf, requiring its own weak-strong pattern.
Example with dispatch_async
__weak typeof(self) weakSelf = self;
[self.manager fetchData:^(NSArray *result) {
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
__weak typeof(strongSelf) weakSelf2 = strongSelf;
dispatch_async(dispatch_get_main_queue(), ^{
typeof(strongSelf) strongSelf2 = weakSelf2;
if (strongSelf2) {
strongSelf2.data = result;
[strongSelf2 updateUI];
}
});
}
}];
Time cost 1 minute per nesting level
Pattern 4: Guard Condition Edge Cases
PRINCIPLE The guard condition if (strongSelf) must be correct. Common mistakes: forgetting the guard, wrong condition, or mixing self and strongSelf.
❌ WRONG (Multiple guard failures)
__weak typeof(self) weakSelf = self;
[self.button setTapAction:^{
typeof(self) strongSelf = weakSelf;
// MISTAKE 1: Forgot guard condition
self.counter++; // CRASH! self is deallocated, accessing freed object
// MISTAKE 2: Guard exists but used wrong variable
if (weakSelf) {
[weakSelf doWork]; // weakSelf is weak, might become nil again
}
// MISTAKE 3: Mixed self and strongSelf
if (strongSelf) {
self.flag = YES; // Used self instead of strongSelf!
[strongSelf doWork];
}
}];
✅ CORRECT (Proper guard and consistent usage)
__weak typeof(self) weakSelf = self;
[self.button setTapAction:^{
typeof(self) strongSelf = weakSelf;
if (strongSelf) {
// CORRECT: Use strongSelf everywhere, never self
strongSelf.counter++;
strongSelf.flag = YES;
[strongSelf doWork];
}
// If strongSelf is nil, entire block skips gracefully
}];
Why this works
if (strongSelf)checks if object still exists- If it does, strongSelf is a strong reference (safe)
- If it doesn't (object deallocated), block skips
- Using strongSelf everywhere prevents accidental self references
Critical rules (MANDATORY, no exceptions)
- ✅ ALWAYS check
if (strongSelf)before using it - ✅ ALWAYS use strongSelf inside the if block, NEVER direct self
- ✅ strongSelf is guaranteed valid for the entire block scope
- ❌ NEVER use
if (!strongSelf) return;(confuses logic) - ❌ NEVER skip the guard to "save code"
- ❌ NEVER mix weakSelf and strongSelf access
- ❌ NEVER use strongSelf without guard (GUARANTEED crash)
What happens if you get it wrong
- No guard: Crashes with "Sending message to deallocated instance"
- Wrong condition: Object still deallocated, still crashes
- Mixed self/strongSelf: One accidental self defeats entire pattern
- Using strongSelf without guard: GUARANTEED crash when object is deallocated
Inside the guard
if (strongSelf) {
strongSelf.data1 = value1;
[strongSelf doWork1];
[strongSelf doWork2]; // All safe
}
// ❌ WRONG: Using strongSelf after guard ends
strongSelf.data = value2; // CRASH! Outside guard
What NOT to do
// ❌ FORBIDDEN: strongSelf without guard guarantees crash
typeof(self) strongSelf = weakSelf;
strongSelf.data = value; // CRASH if weakSelf is nil!
// ✅ MANDATORY: Always guard before using strongSelf
if (strongSelf) {
strongSelf.data = value; // Safe
}
Time cost 10 seconds per block to verify guard is correct
Quick Reference Table
| Issue | Check | Fix |
|---|---|---|
| Memory not returning to baseline | Does ViewController still exist in Memory Graph? | Apply Pattern 1 (weak-strong) |
| Crash: "message to deallocated instance" | Is guard condition missing or wrong? | Apply Pattern 4 (correct guard) |
| Applied weak-strong but still leaking | Are ALL self references using strongSelf? | Check for mixed self/strongSelf |
| Block contains NSAssert or NSLog | Do they reference self? | Apply Pattern 2 (use strongSelf in macros) |
| Nested blocks | Is weak-strong applied to EACH level? | Apply Pattern 3 (guard every block) |
| Not sure if block creates cycle | Is block assigned to self or self.property? | If yes, apply Pattern 1 |
When You're Stuck After 30 Minutes
If you've spent >30 minutes and the leak still exists:
STOP. You either
- Skipped a mandatory diagnostic step (most common)
- Didn't apply weak-strong to ALL blocks (nested blocks missed)
- Have hidden self reference (NSAssert, NSLog, string format)
- Applied pattern but mixed in direct
selfreferences - Have a different kind of leak (not block-related)
MANDATORY checklist before claiming "skill didn't work"
- I ran all 4 diagnostic blocks (Allocations, Memory Graph, block search, self reference search)
- I confirmed memory doesn't return to baseline in Instruments
- I confirmed ViewController is still allocated (not deallocated)
- I traced the retention chain (what's holding the ViewController?)
- I found ALL blocks that capture self (global search:
[selfin the file) - I checked for hidden self references (NSAssert, NSLog, string formatting)
- I applied weak-strong pattern to outer blocks
- I applied weak-strong pattern to nested blocks (every nesting level)
- I verified NO direct
selfreferences remain (only strongSelf) - I ran Instruments again and memory returned to baseline
- I tested on real device, not just simulator
- I cleared Xcode derived data between runs
If ALL boxes are checked and still leaking
- You have a non-block leak (Core Data, timer, delegate, notification)
- Use Instruments > Leaks instrument to identify the actual cycle
- Profile for 2-3 minutes: open screen, close screen, repeat 5 times
- Look at "Leaks" panel—it shows exactly what's not being released
- Time cost: 15-30 minutes to identify the real culprit
If you identify it's NOT a block leak
- Do not rationalize: "Maybe blocks are fine, I'll ship anyway"
- Find the actual cycle (could be delegate, timer, property observer, notification)
- Fix the real issue, not a false positive
Time cost transparency
- Pattern 1: 30 seconds per block
- Pattern 2: 1 minute per block (audit for hidden self)
- Pattern 3: 1 minute per nesting level
- Nested diagnostics if stuck: 15-30 minutes
- Total for straightforward leak: 5-10 minutes
Common Mistakes
❌ Forgetting the guard condition
strongSelf.property = value;withoutif (strongSelf)- Crash when object is deallocated
- Fix: ALWAYS use
if (strongSelf) { ... }
❌ Mixing self and strongSelf in same block
self.flag = YES; [strongSelf doWork];- One direct
selfreference defeats the entire pattern - Fix: ONLY use strongSelf inside the block
❌ Applying pattern to outer block only
- Nested block still captures strongSelf strongly
- Still leaks
- Fix: Apply weak-strong to EVERY block
❌ Using __unsafe_unretained as "workaround"
- ❌ FORBIDDEN pattern—unsafe and crashes
- Creates crashes when object is deallocated
- Not a solution, worse problem
- Fix: Use weak-strong pattern instead
❌ Not checking for hidden self references
NSLog(@"Value: %@", self.property)in a block- Leak still exists even after applying weak-strong
- Fix: Audit for NSAssert, NSLog, string formatting
❌ Rationalizing "it's a small leak"
- Single block leak might be 100KB
- After 50 screens, accumulates to 5MB
- Eventually app crashes from memory pressure
- Fix: Fix every block leak, don't rationalize
❌ Assuming blocks in system frameworks are safe
- UIView animations, AFNetworking, dispatch, timers
- ALL can retain blocks that reference self
- Fix: Apply weak-strong pattern regardless of source
❌ Testing only in simulator
- Simulator memory pressure is different
- Leak might not appear until real device under load
- Fix: Test on real device, oldest supported model
Real-World Impact
Before Block memory leak debugging 2-3 hours per issue
- Run Allocations, not sure what to look at
- Search everywhere, no clear diagnostic path
- Try random fixes, hope one works
- Ship anyway after sunk cost fallacy
- Customer reports crashes or slowdown
After 5-10 minutes with systematic diagnosis
- Run Allocations, confirm memory not returning to baseline
- Memory Graph shows exactly what's retained
- Find all blocks capturing self with global search
- Apply weak-strong pattern (30 seconds per block)
- Test in Instruments, memory returns to baseline
- Done
Key insight Block retain cycles are 100% preventable with weak-strong pattern. There are no exceptions, no "special cases" where strong self is acceptable.
Last Updated: 2025-11-30 Status: TDD-tested with pressure scenarios Framework: Objective-C, blocks (closure), ARC