iOS Memory Management: From ARC to Retain Cycles

I once spent three days debugging a memory leak that was consuming 400 MB in a production app. The symptom was innocuous — the app got progressively slower the longer you used it. The root cause? A simple retain cycle between a view controller and a closure that captured self implicitly. The fix was one [weak self] annotation. But finding it took a Memory Graph deep-dive, three Instruments trace recordings, and a lot of swearing.

Memory management in iOS is one of those topics that seems simple on the surface — until it isn’t. ARC handles most of the heavy lifting, but when things go wrong, they go wrong silently. The app doesn’t crash with a clear error. It just… leaks. Slowly, steadily, until the OS kills it.

In this post, I’ll take you from the fundamentals of ARC through to real-world debugging, covering everything you need to know as a senior iOS developer.

1. ARC Fundamentals: How Automatic Reference Counting Actually Works

Let’s start with the basics — because even experienced developers benefit from refreshing the mental model.

ARC (Automatic Reference Counting) is Swift’s memory management system. Every time you create a new reference to a class instance, ARC increments a retain count. Every time a reference goes away, it decrements that count. When the count reaches zero, the object is deallocated immediately.

Only class instances and actors are reference-counted. Structs and enums are value types with a different memory model — they copy on assignment. As I covered in Struct vs Class in Swift: Making the Right Choice, value types copy on assignment and don’t participate in reference counting at all. This is one of the key reasons structs are safer in concurrent code — there’s no shared reference to manage.

class User {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) initialized")
    }
    deinit {
        print("\(name) deallocated")
    }
}

var user1: User? = User(name: "Alice") // retain count: 1
var user2 = user1                       // retain count: 2
user1 = nil                             // retain count: 1
user2 = nil                             // retain count: 0 → deinit called

ARC is efficient. It inserts retain and release calls at compile time — there’s no garbage collection pause, no sweeping phase. The deallocation happens deterministically the moment the last reference is gone.

But here’s the key insight that trips people up: ARC is deterministic, but that doesn’t mean it’s simple. With closures, delegation, and asynchronous code, the chain of references can become incredibly complex.

2. Strong, Weak, and Unowned: Choosing the Right Reference Type

Swift gives you three qualifiers for reference types. Choosing incorrectly is the single most common source of memory bugs.

Strong References (The Default)

Every reference is strong by default. A strong reference keeps the object alive. This is what you want 90% of the time — parent objects holding children, collections holding their elements, view controllers holding their views.

class Parent {
    let child: Child
    init(child: Child) { self.child = child }
}

class Child {
    let name: String
    init(name: String) { self.name = name }
}

This is fine: the Parent holds a strong reference to Child, but Child doesn’t hold a reference back to Parent. No cycle.

Weak References

A weak reference does not keep the object alive. It must be declared as Optional because the object can be deallocated at any time, setting the reference to nil.

Use weak for delegate patterns, observers, and any relationship where the referenced object may outlive the referencer or can be deallocated independently.

protocol MyDelegate: AnyObject {
    func didSomething()
}

class ViewController {
    weak var delegate: MyDelegate?
    
    func doSomething() {
        delegate?.didSomething()
    }
}

The AnyObject constraint on the protocol ensures only classes can conform — this is a core pattern in Swift’s protocol-oriented approach, as explored in Why Swift Is Protocol-Oriented. The weak on the delegate property ensures no retain cycle when the delegate (typically a parent) holds a strong reference back to the view controller.

Unowned References

An unowned reference also does not keep the object alive, but unlike weak, it’s non-optional. You’re telling the compiler: “I guarantee this object will outlive me.”

Use unowned when you have a parent-child relationship where the child should never exist without the parent. A classic example:

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deallocated") }
}

class CreditCard {
    let number: String
    unowned let customer: Customer
    init(number: String, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    deinit { print("Card \(number) is being deallocated") }
}

A credit card always belongs to a customer. If the customer is deallocated, the card should be too — there’s no scenario where a card exists without its owner. Using unowned avoids the optional unwrapping that weak would require.

But be careful. If you access an unowned reference after the object has been deallocated, your app crashes. This is not a leak — it’s a dangling pointer crash, and it’s unrecoverable.

var customer: Customer? = Customer(name: "Bob")
let card = CreditCard(number: "1234", customer: customer!)
customer = nil
print(card.customer.name) // 🚨 CRASH: Fatal error: Attempted to read an unowned reference

When to Use What

SituationUse
Delegates, data sources, observersweak
Parent holds child, child refers back to parentunowned on child
Capturing self in a closure that doesn’t outlive selfunowned self
Capturing self in a closure that may outlive selfweak self
Protocol properties where the conformer is a classweak + AnyObject protocol

3. Retain Cycles: The Silent Memory Killer

A retain cycle occurs when two or more objects hold strong references to each other, directly or through a chain. ARC never decrements their retain counts to zero, so they leak.

class ViewController {
    var onTap: (() -> Void)?
    
    func setup() {
        onTap = {
            self.view.backgroundColor = .red // self is captured strongly
        }
    }
}

Here, ViewController holds a strong reference to the closure (via onTap), and the closure captures self strongly. Neither can be deallocated.

The fix:

func setup() {
    onTap = { [weak self] in
        self?.view.backgroundColor = .red
    }
}

Common Retain Cycle Patterns

Pattern 1: Closure Capture

The most common cycle in modern Swift. Any time a class holds a closure property that captures self, you have a potential cycle.

class NetworkManager {
    var completionHandler: ((Data?) -> Void)?
    
    func fetchData() {
        completionHandler = { [weak self] data in
            self?.process(data)
        }
    }
    
    func process(_ data: Data?) { /* ... */ }
}

Pattern 2: Delegate Without Weak

If you forget weak on a delegate property, and the delegate holds a strong reference back to the delegator, you have a cycle.

// ❌ Retain cycle
class CustomView {
    var delegate: CustomViewDelegate? // should be weak
}

Pattern 3: Nested Closures and Timers

Timers are notorious for retain cycles. A timer retains its target, and the target retains the timer:

class TimerViewController {
    var timer: Timer?
    
    func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
    
    func tick() { /* ... */ }
    
    deinit {
        timer?.invalidate() // This won't be called if the cycle exists!
    }
}

Even with [weak self], you need to invalidate the timer in deinit or viewWillDisappear. And if the timer holds the last reference, deinit never runs — it’s a chicken-and-egg problem. Best practice: invalidate timers explicitly in viewDidDisappear or similar lifecycle methods.

Pattern 4: Closures in DispatchQueue

class ImageCache {
    var cache: [String: UIImage] = [:]
    
    func loadImage(url: String) {
        DispatchQueue.global().async { [weak self] in
            // ... download image ...
            DispatchQueue.main.async {
                self?.cache[url] = downloadedImage
            }
        }
    }
}

Always check your async closures for [weak self] — especially nested ones. Each async block is a separate closure that captures the outer scope.

4. Deinit and Object Lifecycle

deinit is called when an object’s retain count reaches zero. It’s your opportunity to clean up — invalidate timers, remove observers, close file handles.

class LifecycleDemo {
    var observer: NSObjectProtocol?
    
    init() {
        observer = NotificationCenter.default.addObserver(
            forName: UIApplication.didEnterBackgroundNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.handleBackground()
        }
    }
    
    func handleBackground() { /* ... */ }
    
    deinit {
        if let observer = observer {
            NotificationCenter.default.removeObserver(observer)
        }
        print("LifecycleDemo deallocated")
    }
}

Pro tip: Add deinit logging to every view controller and model object during development. It catches leaks immediately:

class MyViewController: UIViewController {
    deinit {
        print("\(type(of: self)) deallocated")
    }
}

If you navigate away from a screen and don’t see the deinit log, you have a leak. This is the simplest debugging technique in the book, and I use it in every project.

5. Bridging Costs Between Swift and Objective-C

When you use Foundation types in Swift — String, Array, Dictionary — you’re actually using types that bridge to their Objective-C counterparts: NSString, NSArray, NSDictionary. This bridging has a memory cost.

// This looks innocent, but there's bridging happening:
let swiftString: String = "Hello"
let nsString: NSString = swiftString as NSString // Bridge

// The reverse is more common (and also has cost):
let foundationArray: NSArray = [1, 2, 3]
let swiftArray = foundationArray as? [Int] ?? [] // Bridge + safe unwrap

When you bridge:

  1. Swift retains the underlying Objective-C object
  2. For toll-free bridged Core Foundation types the conversion is cheap, but Swift String’s extended grapheme cluster and possible UTF-8 storage mean bridging to NSString can involve a small cost
  3. For collection types, bridging may involve copying elements and type-checking

The practical impact is small for occasional use. But in hot paths — processing thousands of strings in a loop, for example — the bridging cost adds up.

Best practice: Use native Swift types everywhere you can. Only bridge to Objective-C types when you’re calling an API that requires them (and even then, consider wrapping the API).

// ❌ Unnecessary bridging in a hot loop
for i in 0..<10_000 {
    let str = "item_\(i)" as NSString
    someObjcMethod(str)
}

// ✅ Keep it in Swift land
for i in 0..<10_000 {
    let str = "item_\(i)"
    someSwiftMethod(str)
}

6. Memory Graph Debugging in Xcode

Xcode’s Memory Graph Debugger is your best friend for finding leaks. Here’s how I use it:

Step 1: Run the app, navigate to the screen you suspect is leaking.

Step 2: Navigate away from that screen so it should be deallocated.

Step 3: Click the Memory Graph Debugger button in the debug bar (it looks like a diamond with three circles).

Step 4: In the left sidebar, look for objects that should have been deallocated. Type their class name in the filter bar.

Step 5: Click on a leaked object. The graph shows all references to it — the red path is the retain cycle.

Step 6: Look for the unexpected strong reference that’s keeping it alive.

(In Xcode, the retain cycle path is highlighted in red, making it immediately obvious what’s wrong.)

A Real-World Walkthrough

Let me walk you through a leak I found last month.

The team noticed that the app’s memory grew by about 15 MB every time a user opened the profile screen and went back. After 20 trips, memory was 300+ MB.

I opened the Memory Graph after navigating back from the profile. I filtered for “ProfileViewController” — there it was, still allocated. I clicked it.

The graph showed:

ProfileViewController
  └─ tableView (UITableView)
       └─ dataSource (ProfileDataSource)
            └─ onProfileImageTap (closure)
                 └─ ProfileViewController (strong) ← RETAIN CYCLE

The ProfileDataSource held a closure (onProfileImageTap) that captured self (the view controller) strongly. The view controller held the data source strongly via the table view. Cycle.

The fix: [weak self] in the closure.

// Before (leaking)
dataSource.onProfileImageTap = {
    self.showImagePicker() // strong capture
}

// After (fixed)
dataSource.onProfileImageTap = { [weak self] in
    self?.showImagePicker()
}

One [weak self] saved 15 MB per navigation. That’s the memory management life.

Using Instruments for Deeper Analysis

The Memory Graph Debugger is great for catching leaks in development, but Instruments gives you the full picture:

  1. Leaks Instrument: Detects leaked objects automatically. It shows you where they were allocated and the reference chain preventing deallocation.

  2. Allocations Instrument: Shows all objects in memory. Use “Mark Generation” before and after an action (like navigating to a screen and back) to see what grew.

  3. VM Tracker: Shows virtual memory regions. Useful for finding non-object memory growth (images, data buffers, etc.).

I typically start with the Memory Graph Debugger for quick wins, then switch to Instruments for systemic investigation.

7. Large Object Patterns: Caches, Singletons, and Global State

Caches and singletons are necessary in many apps, but they’re also a common source of unintentional memory retention.

Caches

A cache that never evicts is just a slow leak. Always use NSCache or implement an eviction policy:

class ImageCache {
    private let cache = NSCache<NSString, UIImage>()
    
    init() {
        cache.countLimit = 100        // Max 100 images
        cache.totalCostLimit = 50 * 1024 * 1024  // 50 MB limit
    }
    
    func get(_ key: String) -> UIImage? {
        cache.object(forKey: key as NSString)
    }
    
    func set(_ image: UIImage, for key: String) {
        cache.setObject(image, forKey: key as NSString)
    }
}

NSCache automatically evicts objects when memory is low. It’s thread-safe. It’s built for exactly this purpose.

Contrast with a Dictionary-based cache:

// ❌ Never evicts — unbounded memory growth
class BadImageCache {
    var cache: [String: UIImage] = [:]
}

This is fine for a small, bounded set of keys. It’s dangerous for user-generated content or network responses.

Singletons

Singletons are reference types that live for the entire app process. They never get deallocated. Any object a singleton references strongly also lives forever. This is why architecture guides like MVVM with Clean Architecture in iOS argue that shared state should live behind a protocol and be injected, rather than parked in a global singleton. For a closer look at when the Singleton pattern actually earns its keep, see Design Patterns in Swift: A Practical Guide.

class AppStateManager {
    static let shared = AppStateManager()
    
    var currentUser: User?        // Lives as long as the singleton
    var largeDataCache: [String: Data] = [:]  // Lives forever
    
    private init() {}
}

If currentUser holds references to heavy objects (profile images, message history), those never get released — even after the user logs out.

Mitigation:

class AppStateManager {
    static let shared = AppStateManager()
    
    private weak var _currentUser: User?  // Weak — doesn't keep it alive
    private(set) var currentUserId: String?
    
    func login(_ user: User) {
        _currentUser = user
        currentUserId = user.id
    }
    
    func logout() {
        _currentUser = nil
        currentUserId = nil
        // Other code can now deallocate the User object
    }
}

Or better: don’t store large objects in singletons at all. Keep them in the scene/coordinator that needs them.

8. Autoreleasepool in Tight Loops

The autorelease pool is an optimization inherited from Objective-C. Objects are added to the pool and released when the pool is drained. ARC usually manages this for you, but in tight loops that create many temporary objects, the pool can grow large before it’s drained.

// ❌ Memory could spike
for i in 0..<10_000 {
    let image = UIImage(named: "frame_\(i)")! // Added to autorelease pool
    frames.append(image)
}

// ✅ Drain the pool periodically
for i in 0..<10_000 {
    autoreleasepool {
        let image = UIImage(named: "frame_\(i)")!
        frames.append(image)
    }
}

Each iteration’s temporary objects are released before the next iteration begins, keeping peak memory low.

When do you need autoreleasepool?

  • Creating many UIImage, UIColor, NSString, or other Objective-C objects in a loop
  • Core Graphics operations (CGContext, CGBitmapContext, etc.)
  • Processing large datasets with Foundation types

When do you NOT need it?

  • Pure Swift types (structs, enums, Swift collections) — they’re not managed by the autorelease pool
  • A few objects here and there — the overhead of autoreleasepool isn’t worth it
  • Asynchronous code — each dispatch queue has its own autorelease pool
// These don't need autoreleasepool — pure Swift types
for i in 0..<10_000 {
    let point = CGPoint(x: Double(i), y: Double(i))
    // ...
}

9. Concurrency and Memory: Actors, Sendable, and Data Races

Swift’s concurrency model introduces new memory management considerations.

Actors

Actors are reference types, just like classes. They can participate in retain cycles:

actor DataProcessor {
    var handler: Handler?
    
    func setHandler(_ handler: Handler) {
        self.handler = handler
    }
}

class Handler {
    weak var processor: DataProcessor?
    
    func process() {
        Task { [weak self] in
            await self?.processor?.doWork()
        }
    }
}

The weak on processor is still necessary if there’s a reciprocal strong reference. Actor isolation doesn’t magically solve reference cycles.

Sendable and Memory Safety

Sendable is a protocol that indicates a type is safe to pass across concurrency boundaries — another example of Swift’s protocol-oriented approach at work, as discussed in Why Swift Is Protocol-Oriented. Classes are not implicitly Sendable — they must be explicitly marked, and only under certain conditions:

// Not Sendable — can't pass across actors
class MutableData {
    var value: Int
    init(value: Int) { self.value = value }
}

// Sendable — all properties are immutable and Sendable
final class ImmutableData: Sendable {
    let value: Int
    init(value: Int) { self.value = value }
}

The memory implication: non-Sendable classes passed between concurrency domains require careful management. ARC counts cross-domain references, and if a class is shared between an actor and the main actor, both retain it.

actor ImageLoader {
    private var cache: [String: UIImage] = [:]
    
    func load(url: String) async -> UIImage? {
        if let cached = cache[url] {
            return cached
        }
        // UIImage is Sendable in iOS 16+
        let image = await downloadImage(url: url)
        cache[url] = image
        return image
    }
}

In Swift 6, the compiler enforces Sendability strictly. If you try to pass a non-Sendable class instance across an actor boundary, you get a compile-time error. This isn’t just a safety feature — it’s a memory management feature, because it prevents the kind of shared mutable state that leads to both data races and subtle retention issues.

Task Local Values and Memory

TaskLocal values are stored per-task and participate in reference counting:

enum TaskStorage {
    @TaskLocal static var requestID: String?
}

Task {
    TaskStorage.$requestID.withValue("req-123") {
        // Inside here, requestID is "req-123"
        // The TaskLocal retains its value for the duration of the scope
        await doWork()
    }
}

TaskLocals are cleaned up automatically when the scope exits, but if you store large objects in them, be mindful of the lifetime of the enclosing task.

10. Real-World Leak Investigation Walkthrough

Let me tie everything together with a real investigation I conducted recently.

The Symptoms

  • App memory grew by 50 MB every time the user completed a purchase flow
  • The purchase flow involved: Product List → Checkout → Payment → Confirmation
  • Memory never went back down after the flow completed

Step 1: Hypothesis

My first guess was the payment processing — maybe the SDK was holding references. But I wanted evidence, not guesses.

Step 2: deinit Logging

I added deinit logging to every key view controller in the flow:

class ProductListViewController: UIViewController {
    deinit { print("✅ ProductListViewController deallocated") }
}
class CheckoutViewController: UIViewController {
    deinit { print("✅ CheckoutViewController deallocated") }
}
class PaymentViewController: UIViewController {
    deinit { print("✅ PaymentViewController deallocated") }
}
class ConfirmationViewController: UIViewController {
    deinit { print("✅ ConfirmationViewController deallocated") }
}

After completing the flow: every view controller logged deallocation except CheckoutViewController.

Step 3: Memory Graph Inspection

I opened the Memory Graph Debugger after dismissing the flow. CheckoutViewController was still in memory. I clicked it.

The reference chain showed:

CheckoutViewController
  └─ paymentHandler (closure)
       └─ self (CheckoutViewController) ← RETAIN CYCLE

The paymentHandler was a closure property set during checkout initialization. It was never cleared.

Step 4: The Root Cause

class CheckoutViewController {
    private var paymentHandler: ((Bool) -> Void)?
    
    func configurePaymentSDK() {
        paymentHandler = { success in
            if success {
                self.showConfirmation()
            } else {
                self.showError()
            }
        }
        PaymentSDK.shared.onResult = paymentHandler
    }
}

Two problems:

  1. paymentHandler captured self strongly
  2. PaymentSDK.shared (a singleton) held a strong reference to the closure

Even if I fixed #1 (with [weak self]), the singleton would keep the closure alive, which kept a weak reference alive — but the closure itself would be deallocated when CheckoutViewController was deallocated. The real fix was both: use [weak self] and clear the handler in deinit.

Step 5: The Fix

class CheckoutViewController {
    private var paymentHandler: ((Bool) -> Void)?
    
    func configurePaymentSDK() {
        paymentHandler = { [weak self] success in
            guard let self else { return }
            if success {
                self.showConfirmation()
            } else {
                self.showError()
            }
        }
        PaymentSDK.shared.onResult = paymentHandler
    }
    
    deinit {
        PaymentSDK.shared.onResult = nil  // Clear the closure
        print("✅ CheckoutViewController deallocated")
    }
}

Step 6: Verification

After the fix, I ran the same flow five times. Memory stayed flat. The deinit logs confirmed every view controller was released.

Lessons Learned

  1. Always log deinit — it’s the cheapest leak detector you’ll ever use
  2. Singletons + closures = danger — a singleton holding a closure that captures a view controller will keep it alive forever
  3. Clean up in deinit — any closure or observer that your object registered externally should be removed in deinit
  4. Don’t trust SDKs — third-party SDKs often hold strong references to your objects. Profile regularly.

Key Takeaways

  • ARC increments and decrements retain counts deterministically. When the count hits zero, the object is deallocated immediately. No garbage collection pauses.
  • Choose weak or unowned intentionally. Weak for optional relationships where the other object may be deallocated independently. Unowned for guaranteed parent-child lifetimes.
  • Retain cycles are the #1 cause of leaks in Swift. The most common source: closures capturing self strongly while being held by the object that owns them.
  • deinit logging catches leaks instantly. Add deinit { print("\(Self.self) deallocated") } to every view controller in development. You’ll find leaks the moment they appear.
  • Use the Memory Graph Debugger + Instruments together. The Memory Graph shows you what is leaking and why. Instruments (Leaks + Allocations) shows you the system-wide impact.
  • Caches need eviction policies, singletons need weak references. Without these, your app retains memory it doesn’t need.
  • Use autoreleasepool around tight loops with Objective-C types. Pure Swift types don’t need it. Measure first, optimize second.
  • Swift concurrency doesn’t solve retain cycles. Actors are reference types. Sendable prevents data races, not leaks. Apply the same weak/unowned discipline in async code.

Memory management in iOS is less about memorizing rules and more about developing a mental model of object ownership. Every reference is a conscious decision: Who owns this object? How long should it live? What happens when I’m done with it?

Ask yourself those questions, and you’ll be well on your way to leak-free code.