- Published on
> Understanding Retain Cycles in Objective-C
- Authors

- Name
- Mick MacCallum
- @0x7fs
In Objective-C's reference counting world, objects stay alive as long as someone holds a strong reference. When two objects reference each other, neither can be deallocated—even when nothing else needs them. This is a retain cycle, and it's probably responsible for most memory leaks in Objective-C apps.
How Reference Counting Works
Each object has a retain count. When you create or retain an object, the count increases. When you release it, the count decreases. When it hits zero, the object deallocates.
NSObject *obj = [[NSObject alloc] init]; // retain count: 1
[obj retain]; // retain count: 2
[obj release]; // retain count: 1
[obj release]; // retain count: 0, deallocated
Under ARC, the compiler inserts these calls automatically. You don't write them, but they still happen.
The Cycle Problem
Consider two objects that reference each other:
@interface Person : NSObject
@property (nonatomic, strong) Apartment *apartment;
@end
@interface Apartment : NSObject
@property (nonatomic, strong) Person *tenant;
@end
When you connect them:
Person *john = [[Person alloc] init]; // john: 1
Apartment *unit = [[Apartment alloc] init]; // unit: 1
john.apartment = unit; // unit: 2
unit.tenant = john; // john: 2
Now set both variables to nil:
john = nil; // john's count: 2 -> 1 (apartment still holds it)
unit = nil; // unit's count: 2 -> 1 (john still holds it)
Both objects still have retain count 1. Neither deallocates. Memory leaked permanently.
Breaking Cycles with Weak References
The solution is making one reference weak instead of strong:
@interface Apartment : NSObject
@property (nonatomic, weak) Person *tenant; // Changed to weak
@end
Weak references don't increment the retain count:
Person *john = [[Person alloc] init]; // john: 1
Apartment *unit = [[Apartment alloc] init]; // unit: 1
john.apartment = unit; // unit: 2
unit.tenant = john; // john: 1 (weak doesn't retain)
john = nil; // john: 0 -> deallocated
// When john deallocates, unit.tenant automatically becomes nil
unit = nil; // unit: 1 -> 0 -> deallocated
The cycle is broken. Both objects deallocate properly.
Parent-Child Relationships
The general rule: parents own children strongly; children reference parents weakly.
@interface Parent : NSObject
@property (nonatomic, strong) NSArray<Child *> *children; // Strong
@end
@interface Child : NSObject
@property (nonatomic, weak) Parent *parent; // Weak
@end
This pattern works for view hierarchies, data structures, delegate relationships, and more.
Block Retain Cycles
Blocks capture variables by reference, which can create cycles:
@interface DataLoader : NSObject
@property (nonatomic, copy) void (^completion)(NSData *);
@end
@implementation DataLoader
- (void)loadWithCompletion:(void (^)(NSData *))block {
self.completion = block;
[self startLoading];
}
- (void)startLoading {
// When done:
self.completion(loadedData); // Uses self
}
@end
If you use it like this:
loader.completion = ^(NSData *data) {
[self processData:data]; // Captures self strongly
};
The loader holds the block, the block holds self, and if self holds the loader, you have a cycle.
The weak-strong Dance
The canonical solution uses a weak reference to break the cycle:
__weak typeof(self) weakSelf = self;
loader.completion = ^(NSData *data) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return; // Self was deallocated
[strongSelf processData:data];
};
The weak capture prevents the cycle. The strong inside the block ensures self doesn't deallocate mid-execution.
Detecting Retain Cycles
Xcode's Memory Graph Debugger visualizes object relationships. Run your app, click the memory graph button, and look for cycles—objects connected in loops.
Instruments' Leaks tool also catches many retain cycles. It shows leaked objects and their retain history.
For manual debugging, override dealloc and log:
- (void)dealloc {
NSLog(@"%@ deallocated", self);
}
If dealloc never fires when you expect it to, you likely have a cycle.
Common Cycle Sources
Delegates are frequently cycle sources if made strong:
@property (nonatomic, strong) id<SomeDelegate> delegate; // Wrong - causes cycles
@property (nonatomic, weak) id<SomeDelegate> delegate; // Correct
NSTimer retains its target until invalidated:
// This timer retains self, preventing deallocation
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:@selector(tick)
userInfo:nil
repeats:YES];
You must call [self.timer invalidate] explicitly, typically in a method like viewWillDisappear: rather than dealloc (since dealloc won't be called while the cycle exists).
Notification observers with block handlers can capture self:
// Cycle if self holds observer and block holds self
self.observer = [[NSNotificationCenter defaultCenter]
addObserverForName:@"SomeNotification"
object:nil
queue:nil
usingBlock:^(NSNotification *note) {
[self handleNotification]; // Captures self
}];
Use the weak-strong dance here too.
Unsafe Unretained
There's also __unsafe_unretained, which is like weak but doesn't nil out when the object deallocates. It's faster but dangerous—accessing a deallocated object crashes.
@property (nonatomic, unsafe_unretained) id unsafeRef;
Only use this when you're certain the referenced object outlives the reference, and you need the tiny performance gain. Weak is almost always the right choice.
Retain cycles are subtle but predictable. Follow the parent-child ownership rule, use weak for delegates and back-references, and capture self weakly in blocks. Your memory graph will thank you.
// Continue_Learning
NSTaggedPointerString - The Hidden Optimization You've Never Heard Of
Discover how the Objective-C runtime secretly stores small strings directly in pointer values, eliminating heap allocations entirely.
Operator Precedence Gotchas When Moving Between Swift and Objective-C
A refactoring mishap with arithmetic expressions led me down the rabbit hole of operator precedence differences between Swift and Objective-C. Here's what to watch for.
Key-Value Observing Deep Dive
KVO lets you observe property changes without delegation or notifications. Here's how it works under the hood and how to use it correctly.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.