Swift Retain Cycles & Memory Leaks: A Practical Guide
Retain cycles are one of the most common reasons Swift objects stay in memory longer than expected. This article explains how they happen, how Automatic Reference Counting works, and how to fix leaks with better ownership and capture rules.
Quick answer: A retain cycle happens when two or more objects keep strong references to each other, so none of them can be deallocated. In Swift, the usual fixes are weak, unowned, and redesigning ownership so one side does not own the other.
Difficulty: Intermediate
Helpful to know first: You'll understand this better if you know how Swift references work, what optional values are, and the basics of classes versus structs.
1. What Is Retain Cycles & Memory Leaks?
A retain cycle is a situation where objects keep each other alive through strong references. Because Swift uses Automatic Reference Counting, an object is only released when its strong reference count reaches zero.
- A retain cycle is a reference loop that prevents deallocation.
- A memory leak is memory that is no longer needed but still cannot be freed.
- In Swift, retain cycles are a common cause of leaks, especially with closures and class-to-class relationships.
- Value types such as struct and enum do not create reference cycles because they are copied, not reference-counted.
Think of it this way: if object A strongly owns object B, and object B strongly owns object A, neither can reach a reference count of zero. The objects remain in memory even after the rest of your program stops using them.
2. Why Retain Cycles Matter
Retain cycles matter because they create bugs that are often invisible at first. Your app may appear to work, but memory use keeps growing, objects never release resources, and deinit never runs.
They are especially important in long-running apps where screens are shown and dismissed repeatedly. A view controller, service, or model object that leaks every time it is created will eventually cause performance problems and wasted memory.
Retain cycles also affect resource cleanup. If an object owns file handles, observers, timers, or network tasks, failing to deallocate it can leave work running longer than intended.
3. Basic Syntax or Core Idea
The core idea is simple: strong references increase ownership, while weak and unowned references avoid keeping an object alive. The most common fix appears in closure capture lists and class relationships.
Strong reference example
Two class instances can keep each other alive if both properties are strong.
final class Person {
var apartment: Apartment?
}
final class Apartment {
var tenant: Person?
}
Here, each object can own the other. If both properties are set, neither object will be released automatically.
Breaking the cycle with weak
Usually one side should not own the other. Making one side weak prevents the cycle.
final class Person {
var apartment: Apartment?
}
final class Apartment {
weak var tenant: Person?
}
This works because the apartment does not increase the person's strong reference count.
Closure capture list
Closures capture referenced objects strongly by default. A capture list lets you change that behavior.
class Downloader {
var onComplete: ((String) -> Void)?
func start() {
onComplete = { [weak self] result in
self?.handle(result)
}
}
func handle(_ result: String) { }
}
The closure no longer owns self strongly, so the downloader can be released when nothing else uses it.
4. Step-by-Step Examples
Example 1: A simple two-object cycle
This example shows how a pair of classes can accidentally keep each other alive. Notice that both properties are optional class references.
final class Customer {
var card: PaymentCard?
deinit {
print("Customer deinitialized")
}
}
final class PaymentCard {
weak var owner: Customer?
deinit {
print("PaymentCard deinitialized")
}
}
Making owner weak breaks the cycle. When the last strong reference to Customer goes away, both objects can deallocate.
Example 2: Closure stored on the same object
This is one of the most common real-world leaks. The object stores a closure, and the closure uses the object.
final class ProfileViewModel {
var reloadHandler: (() -> Void)?
func configure() {
reloadHandler = { [weak self] in
self?.reloadData()
}
}
func reloadData() {
print("Reloading profile")
}
}
Without the capture list, the closure would keep the view model alive, and the view model would keep the closure alive.
Example 3: Delegation pattern
Delegates should usually be weak because the delegate is often a parent object that should not be retained by the child.
protocol DownloadTaskDelegate: AnyObject {
func downloadDidFinish()
}
final class DownloadTask {
weak var delegate: DownloadTaskDelegate?
func finish() {
delegate?.downloadDidFinish()
}
}
This keeps the child from owning its parent, which avoids a common cycle in object graphs.
Example 4: Timer-related leak
A repeating timer can retain its target closure or callback until it is invalidated. If the closure captures self strongly, the object may never go away.
final class HeartbeatMonitor {
private var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.checkPulse()
}
}
func checkPulse() {
print("Checking pulse")
}
}
Using weak self prevents the timer callback from holding the monitor alive forever.
5. Practical Use Cases
- View models that store completion closures for asynchronous work.
- Delegates in child objects that need to notify a controller or parent.
- Managers that keep timers, observers, or callbacks alive while work is active.
- Closures passed into networking, animation, or delayed execution APIs.
- Object graphs such as document models, tree nodes, and bidirectional relationships.
These patterns are common because they combine long-lived objects with callbacks or back-references. That combination is where leaks usually appear.
6. Common Mistakes
Mistake 1: Capturing self strongly in a stored closure
When a class stores a closure property and that closure uses self, both objects can keep each other alive. This is one of the most frequent retain cycle sources in Swift.
Problem: The closure is stored on the same object and references the object strongly, so the object's reference count never reaches zero.
final class ReportLoader {
var onFinish: (() -> Void)?
func load() {
onFinish = {
self.cleanup()
}
}
func cleanup() { }
}
Fix: Capture self weakly when the closure does not need to keep the object alive.
final class ReportLoader {
var onFinish: (() -> Void)?
func load() {
onFinish = { [weak self] in
self?.cleanup()
}
}
func cleanup() { }
}
The corrected version works because the closure no longer owns the report loader strongly.
Mistake 2: Using weak where the object must stay alive for the work
A weak capture can be too weak if the closure needs the object to stay alive until the task finishes. In that case, the object may disappear before the callback runs.
Problem: The closure may do nothing because self becomes nil before the work completes, which can make the feature appear broken.
final class UploadController {
func upload() {
performAsyncUpload() { [weak self] result in
self?.showCompletion()
}
}
func showCompletion() { }
}
Fix: Use unowned only when the callback cannot outlive the object, or redesign ownership so the callback is tied to a guaranteed lifetime.
final class UploadController {
func upload() {
performAsyncUpload() { [unowned self] result in
self.showCompletion()
}
}
func showCompletion() { }
}
The corrected version works because it matches the lifetime guarantee of the callback instead of dropping the reference too early.
Mistake 3: Forgetting to invalidate a repeating timer or observation
Some APIs keep their callbacks active until you explicitly stop them. If the object also keeps the callback token or timer, the object may never deallocate.
Problem: The timer keeps firing and still references the owning object, so deinit never runs and memory keeps growing.
final class Poller {
private var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
self?.poll()
}
}
func stop() {
timer?.invalidate()
timer = nil
}
func poll() { }
}
Fix: Always stop repeating work when it is no longer needed and clear your strong reference to it.
final class Poller {
private var timer: Timer?
deinit {
timer?.invalidate()
}
}
The corrected version works because the repeating work is shut down instead of keeping the object alive indefinitely.
7. Best Practices
Use weak references for back-references
When a child needs to point back to its parent, the child usually should not own the parent. A weak reference expresses that relationship clearly.
weak var parent: Node?
This is easier to reason about than trying to manually break a cycle later.
Prefer capture lists over blanket weak self patterns
Only capture self weakly when the closure does not require a guaranteed lifetime. Capture the specific values you need when possible.
func prepareMessage() {
let title = "Welcome"
send() { [title] in
print(title)
}
}
Capturing only what you need reduces the chance of accidental ownership.
Break cycles at the ownership boundary
It is cleaner to design ownership so the cycle never forms. If a property is only there for communication, it should often be weak or external to the object graph.
final class SessionManager {
private var completion: (() -> Void)?
}
When you design ownership carefully, you need fewer ad hoc fixes later.
8. Limitations and Edge Cases
- weak references must be optional because the referenced object can disappear at any time.
- unowned references must never be used when the referenced object may deallocate first, or your app can crash.
- Closures capture values strongly by default, including self, unless you add a capture list.
- Value types do not create retain cycles, but they can still store closures that capture class instances strongly.
- Not every memory increase is a leak. Temporary caches, delayed releases, and autorelease behavior can look like leaks during short tests.
- Tools may show objects as still alive briefly if asynchronous work is in progress, so confirm leaks by checking whether deinit runs after work finishes.
Warning: Do not use unowned unless the lifetime relationship is guaranteed. If the object is already gone and the closure runs, your app can crash with a bad access error.
9. Practical Mini Project
Let's build a tiny message sender that avoids retain cycles while still supporting a completion callback. The example combines a service object, a controller, and a stored closure.
final class MessageService {
var completion: ((Bool) -> Void)?
func send(message: String) {
print("Sending: \(message)")
completion?(true)
completion = nil
}
}
final class ChatController {
private let service = MessageService()
func sendGreeting() {
service.completion = { [weak self] success in
guard let self = self else { return }
if success {
print("Message sent from \(self)")
}
}
service.send(message: "Hello")
}
}
This mini project avoids a cycle by clearing the completion after use and by capturing the controller weakly. The service can finish its work without trapping the controller in memory.
10. Key Points
- Retain cycles are strong reference loops that prevent deallocation.
- Memory leaks often appear when closures, delegates, timers, or back-references are owned incorrectly.
- weak breaks ownership without extending lifetime.
- unowned is only for cases where the referenced object is guaranteed to outlive the capture.
- If deinit does not run when expected, check for a reference cycle.
11. Practice Exercise
Try fixing the leak in a small callback-based object graph.
- Create a class named Logger with a stored closure property called onLog.
- Create a method that assigns a closure using self.
- Make sure the closure does not keep the logger alive forever.
- Print a message from inside the closure and confirm the object can still be released.
Expected output: The log message prints, and the object's cleanup path is reachable when the last strong reference is removed.
Hint: Use a capture list and release any stored callback when it is no longer needed.
Solution:
final class Logger {
var onLog: ((String) -> Void)?
func configure() {
onLog = { [weak self] message in
self?.write(message)
}
}
func write(_ message: String) {
print(message)
}
deinit {
print("Logger released")
}
}
12. Final Summary
Retain cycles are one of the most important memory-management issues to understand in Swift because they can silently keep objects alive. They usually happen when strong references flow in both directions, especially through closures, delegates, and timers.
The practical fix is not just adding weak everywhere. Good Swift code uses the right ownership model for the relationship, captures only what is needed, and clears callbacks or repeating work when it is finished.
Once you learn to spot the patterns, the signs become familiar: deinit does not run, memory keeps growing, or a callback outlives its owner. A careful ownership design prevents these issues before they become bugs. Next, study weak versus unowned in more depth and practice identifying ownership in real object graphs.