Retain cycles are the most common cause of memory leaks in Swift, and they are easy to create accidentally — a closure capturing self, a delegate held strongly, a notification center retaining an observer. I traced it back to a closure retain cycle—the view controller held a closure, and the closure captured self. Both objects held onto each other, and neither could release. From that point on, I started paying serious attention to how Swift handles retain cycles.
How Retain Cycles Happen
ARC’s rule is simple: when an object’s reference count drops to zero, it’s deallocated. But if two objects hold strong references to each other, both counts stay above zero forever. That’s a retain cycle.
Closures are the most common source of retain cycles. A closure captures external variables it uses—and by default, it captures them strongly. If a class holds a closure property, and the closure captures self, the cycle is complete: the class holds the closure, and the closure holds the class.
Code Example:
class ViewController {
var closure: (() -> Void)?
func setup() {
// Dangerous: the closure strongly captures self
closure = {
self.view.backgroundColor = .red
}
}
}
Solution 1: weak self — The Most Common Approach
weak is the most widely used approach. It captures self without increasing the reference count. Since self could be deallocated at any time, the captured reference becomes optional, and you must unwrap it before use.
Use weak when you can’t guarantee self is still alive when the closure executes. This covers most asynchronous operations—network requests, GCD async tasks, timer callbacks, and more.
Code Example:
class ViewController {
var closure: (() -> Void)?
func setup() {
// [weak self] makes the closure capture self weakly
closure = { [weak self] in
// Use guard to unwrap - if self is gone, just return
guard let self = self else { return }
self.view.backgroundColor = .red
}
}
func fetchData() {
// URLSession closures are the same pattern
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self = self else { return }
self.handleResponse(data)
}.resume()
}
deinit {
print("ViewController deallocated")
}
}
guard let self is the most common pattern in production code. If self still exists, execution continues. If it’s been deallocated, it returns early. This is cleaner than optional chaining with self?. everywhere—especially when self appears multiple times in the closure.
One detail:
[weak self]captures a weak reference. When the closure executes,selfcould already benil. Usingguardto unwrap is the safest approach. If you’re usingselfmultiple times in the closure,guard letis cleaner than writingself?.repeatedly.
Solution 2: unowned self — Performance at the Cost of Certainty
unowned doesn’t increase the reference count either. But the difference is: unowned assumes self is definitely still alive when the closure executes. If self has been deallocated, accessing an unowned self crashes immediately.
Use unowned when you are 100% certain self outlives the closure. A classic example is UIView.animate—during the animation, self (usually a view controller) is guaranteed to still be alive.
Code Example:
class Animator {
func animate(view: UIView) {
// The animation closure always executes before self is deallocated
UIView.animate(withDuration: 0.3) { [unowned self] in
self.view.alpha = 0.5
}
}
}
Another example is two properties that share a tied lifecycle. For instance, a Customer and a CreditCard—a credit card always belongs to a customer. If the customer is gone, the credit card has no reason to exist.
Code Example:
class Customer {
let name: String
var card: CreditCard?
init(name: String) { self.name = name }
}
class CreditCard {
let number: String
// The credit card's lifecycle is tied to its customer
unowned let customer: Customer
init(number: String, customer: Customer) {
self.number = number
self.customer = customer
}
}
weak vs unowned: How to Choose
The choice comes down to lifetime relationships.
If two objects have completely independent lifecycles—one can be deallocated without affecting the other—use weak. For example, a view controller and its network request. The request callback might arrive after the controller is dismissed. weak is the safe choice.
If one object’s lifecycle is tied to another—use unowned. For example, Customer and CreditCard. The card is meaningless without its customer.
If you’re unsure, use weak. It’s safer, and the unwrapping step is a small price to pay for avoiding crashes.
Detecting Retain Cycles with Instruments
In a recent project, I spent half a day chasing a memory leak where a chat screen kept accumulating 5MB every time it was opened and dismissed. The deinit print never showed up — classic retain cycle. Here’s how I found and fixed it using Xcode’s Memory Graph Debugger:
// Step 1: Add deinit to every view controller for quick detection
class ChatViewController: UIViewController {
var onMessage: ((String) -> Void)?
func setup() {
// BUG: closure captures self strongly
onMessage = { [weak self] message in
self?.appendMessage(message) // Fixed with [weak self]
}
}
deinit {
print("ChatViewController deallocated ✅")
// If this never prints after dismissal, you have a retain cycle
}
}
// Step 2: Use Xcode Memory Graph Debugger
// - Run the app in Debug mode
// - Click the Debug Memory Graph button (folder icon in debug bar)
// - Look for red circles with number = instances that should be deallocated
// - Click on the leaked object to see the reference chain
// - The arrows show who holds strong references to whom
// Step 3: Use Instruments → Leaks
// - Product → Profile (Cmd+I)
// - Select "Leaks" template
// - Run the app, perform the action that leaks
// - Leaks instrument shows red blocks when memory doesn't decrease
// - Click a leak → see the exact call stack showing where the retain happened
// Step 4: Quick console detection
// Add this to AppDelegate for a global safety net:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Enable zombie objects in Debug to detect use-after-free
// (Not retain cycles, but related memory issues)
return true
}
// In Xcode: Edit Scheme → Run → Diagnostics → Enable Zombie Objects
The Memory Graph Debugger is the fastest way to spot retain cycles — it shows you the exact reference chain visually. The red circles indicate leaked objects, and the arrows show which references are keeping them alive. Once you see the cycle, the fix is usually adding [weak self] to one of the closures in the chain.
Solution 3: The Strong Capture Variant — [self] vs [weak self]
Swift 5.3 introduced [self] in capture lists. But in most cases, you actually want [weak self], not [self].
[weak self] is weak and requires unwrapping. [self] is strong—it keeps self alive for the duration of the closure. But it doesn’t solve retain cycles. If the closure is long-lived, [self] can still cause memory leaks.
The rule is simple: in scenarios that could create a retain cycle, use [weak self]. Don’t use [self]. Use [self] only when the closure isn’t stored long-term—for example, in DispatchQueue.main.async for simple operations that don’t hold onto the closure.
Practical Advice
Every time you write a closure property inside a class, ask yourself: is this closure going to be held onto for a long time? If yes, make sure any reference to self inside it uses [weak self].
Another good practice: add a deinit with a print statement. If you dismiss the screen but the print never shows up, you’ve got a retain cycle. Xcode’s Memory Graph tool can show you the reference graph directly—it’s much faster than guessing.

2 Comments
Solved my long-standing development troubles, thanks a lot!
Great writing, problem solved