Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 046 / 100

Memory management in Swift: patterns, pitfalls and fixes

ARC and retain cycles.

Expona Admin7 min read

ARC counts strong references

Swift's Automatic Reference Counting (ARC) is the foundation of memory management in iOS apps. Every time you create an instance of a class and assign it to a variable or property, Swift increments a reference count. When that reference is removed—either by reassignment or by going out of scope—the count decrements. When the count reaches zero, the instance is deallocated and its memory is reclaimed.

Think of ARC as a ledger. Each strong reference is an entry. The system audits this ledger constantly, and only when all entries are cleared does the object disappear. This automatic process eliminates manual memory management, but it doesn't eliminate all problems. Understanding how ARC tracks references is essential for writing efficient, leak-free code.

Consider a simple view controller managing a network request:

swift
class UserViewController: UIViewController {
    var user: User?
    
    func fetchUser(id: Int) {
        let api = UserAPI()
        api.getUser(id: id) { [weak self] result in
            switch result {
            case .success(let user):
                self?.user = user
            case .failure(let error):
                print("Error: \(error)")
            }
        }
    }
}

class UserAPI {
    func getUser(id: Int, completion: @escaping (Result) -> Void) {
        // Network call happens here
        // completion is called later
    }
}

Here, ARC tracks the strong reference from UserViewController to the User instance stored in the user property. As long as the view controller exists, the user object stays in memory. This is correct behavior—we want that reference to persist.

Cycles leak memory

A retain cycle occurs when two or more objects hold strong references to each other, either directly or through a chain of properties. When this happens, ARC can never reach a reference count of zero for either object, even if they're no longer needed by the rest of your app. Both objects remain in memory indefinitely, consuming resources and creating a memory leak.

Retain cycles are insidious because the app often appears to work fine. Memory usage grows slowly. Users notice performance degradation or crashes after extended use. By then, the leak is hard to trace without proper debugging tools.

Here's a common retain cycle in delegation:

swift
class NetworkManager {
    var delegate: NetworkDelegate?
    
    init(delegate: NetworkDelegate) {
        self.delegate = delegate
        // NetworkManager holds a strong reference to delegate
    }
    
    func startRequest() {
        // Simulates a network call
        delegate?.requestDidComplete()
    }
}

protocol NetworkDelegate {
    func requestDidComplete()
}

class DataController: NetworkDelegate {
    var networkManager: NetworkManager?
    
    init() {
        networkManager = NetworkManager(delegate: self)
        // DataController holds a strong reference to networkManager
        // networkManager holds a strong reference back to DataController
        // Retain cycle created
    }
    
    func requestDidComplete() {
        print("Request complete")
    }
}

When you create an instance of DataController, it holds networkManager as a strong reference. Simultaneously, networkManager holds DataController (via its delegate) as a strong reference. Even if you set networkManager to nil, both objects stay alive because each holds the other.

In more complex scenarios, cycles form through chains of properties. A holds B, B holds C, and C holds A. The pattern is the same: zero objects can be deallocated because the reference counts never drop to zero.

Weak and unowned break cycles

To break retain cycles, Swift provides two tools: weak and unowned. Both create references that don't increment the ARC reference count. Choosing between them depends on the lifetime relationship between objects.

Weak references are optional. They automatically become nil when the referenced object is deallocated. Use weak when the referenced object may outlive the object holding the reference, or when you're uncertain about lifetimes. Weak is the safer choice.

swift
class NetworkManager {
    weak var delegate: NetworkDelegate?
    
    init(delegate: NetworkDelegate) {
        self.delegate = delegate
        // Now this is a weak reference; ARC count doesn't increment
    }
    
    func startRequest() {
        delegate?.requestDidComplete()
    }
}

class DataController: NetworkDelegate {
    var networkManager: NetworkManager?
    
    init() {
        networkManager = NetworkManager(delegate: self)
        // Now there's no retain cycle
        // DataController holds networkManager (strong)
        // networkManager holds DataController weakly
    }
    
    func requestDidComplete() {
        print("Request complete")
    }
}

With the delegate marked as weak, the retain cycle is broken. When DataController is deallocated, networkManager loses its strong reference to it. The delegate pointer safely becomes nil.

Unowned references are non-optional and assume the referenced object outlives the reference holder. They're slightly more efficient than weak because they don't need nil-checking, but they're dangerous if your assumption is wrong. A dangling unowned reference causes a crash.

swift
class Transaction {
    let id: UUID
    unowned let account: BankAccount
    
    init(id: UUID, account: BankAccount) {
        self.id = id
        self.account = account
        // account is expected to outlive this transaction
    }
}

class BankAccount {
    var transactions: [Transaction] = []
    
    func addTransaction() -> Transaction {
        let transaction = Transaction(id: UUID(), account: self)
        transactions.append(transaction)
        return transaction
    }
}

Here, a Transaction cannot exist without its BankAccount. The account logically owns the transaction, so it's safe to use an unowned reference. If you deallocate the account while transactions still exist, you'll crash—but that's a logic error in your app, not a subtle memory leak.

A practical rule: use weak by default in closures and delegation patterns. Use unowned only when you're certain the referenced object will always outlive the reference holder.

Memory Graph Debugger finds leaks

Xcode's Memory Graph Debugger is your best tool for finding and verifying retain cycles. It captures a snapshot of your app's object graph and shows you which objects are alive, which are deallocated as expected, and which form cycles.

To use the Memory Graph Debugger:

  1. Run your app in the debugger.
  2. Interact with your app to trigger the suspected leak (navigate away from a view controller, close a dialog, etc.).
  3. Click the Memory Graph button in Xcode's debug toolbar (it looks like a filled circle), or press Cmd+Shift+2.
  4. Xcode pauses execution and displays the object graph.
  5. Look for purple warning triangles—these indicate leaked objects.
  6. Click a leaked object to inspect its retain count and the references holding it.
  7. Trace the chain of references to find the cycle.

The debugger shows you exactly which properties are creating strong references. In our earlier DataController and NetworkManager example, before adding weak, the debugger would highlight both objects with a purple triangle and show the bidirectional reference chain.

After applying weak, the debugger confirms the cycle is broken. Objects deallocate as expected, and the purple triangles disappear. This visual confirmation is invaluable—it lets you verify your fix actually works rather than relying on assumptions.

Common mistakes

Forgetting weak in closure captures: Closures automatically capture variables strongly. In completion handlers and async callbacks, this creates cycles when the closure's owner is also the captured object.

swift
class ViewController: UIViewController {
    func loadData() {
        fetchData { result in
            // Without [weak self], this closure captures ViewController strongly
            self.displayResult(result)
            // Cycle: ViewController owns the closure, closure owns ViewController
        }
    }
    
    func loadDataFixed() {
        fetchData { [weak self] result in
            // With [weak self], the cycle is broken
            self?.displayResult(result)
        }
    }
}

Using unowned when weak is safer: Unowned references assume lifetimes you may not fully control, especially in large codebases. A small refactor elsewhere can invalidate your assumption, causing crashes.

Ignoring retained cycles in third-party frameworks: When integrating external SDKs, check if they retain delegates or completion handlers. If so, you must use weak self in callbacks even if the pattern seems unusual.

Not testing memory behavior across app states: A retain cycle might not manifest in basic testing. Navigate away from screens, present and dismiss modals, and rotate the device. Use Xcode's low memory warning simulator to stress test your memory management.

Key takeaways

  • ARC automatically manages memory by counting strong references; when the count reaches zero, the object is deallocated.
  • Retain cycles occur when objects hold strong references to each other, preventing deallocation even when they're no longer needed.
  • Break cycles using weak (safer, optional, use by default) or unowned (unsafe if assumption is wrong, use when you're certain of lifetimes).
  • Always use [weak self] in closures to prevent the closure from retaining its owner.
  • The Memory Graph Debugger visually identifies leaked objects and the reference chains causing them—use it regularly during development.
  • Test memory behavior across realistic app workflows: navigation, modal presentation, background states, and device orientation changes.
  • Delegation patterns and async callbacks are the most common sources of retain cycles; apply weak references as a habit in these contexts.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles