iosNudge

What is a retain cycle, and how do you break one?

MediumAsked oftenMemory & ARC 2 min read

TL;DR

A retain cycle is when two or more objects hold strong references to each other. Their reference counts never reach zero. They are never freed. This is a memory leak. Break it by making one link weak or unowned. Common sources: closures that capture self, delegates, parent-child links, timers and notification observers.

The simplest cycle

final class Person {
    var apartment: Apartment?
    deinit { print("Person freed") }
}

final class Apartment {
    var tenant: Person?            // strong
    deinit { print("Apartment freed") }
}

var p: Person? = Person()
var a: Apartment? = Apartment()
p?.apartment = a
a?.tenant = p

p = nil
a = nil
// Nothing is printed. Both objects leak.

Each object keeps the other one alive.

The fix

final class Apartment {
    weak var tenant: Person?       // weak breaks the cycle
}

Now when p = nil, the person is freed. Then the apartment is freed too.

Cycle 1: closures capturing self

The most common cycle in real apps.

final class ProfileViewModel {
    var name = ""
    var onChange: (() -> Void)?

    init() {
        onChange = {
            print(self.name)       // closure holds self strongly
        }                          // self holds closure strongly
    }
}

Fix with a capture list:

onChange = { [weak self] in
    guard let self else { return }
    print(self.name)
}

Cycle 2: strong delegates

protocol PlayerDelegate: AnyObject { func didFinish() }

final class Player {
    var delegate: PlayerDelegate?        // bad: strong
}

final class PlayerViewController: UIViewController, PlayerDelegate {
    let player = Player()
    override func viewDidLoad() {
        super.viewDidLoad()
        player.delegate = self           // VC -> player -> VC
    }
    func didFinish() { }
}

Fix: weak var delegate: PlayerDelegate?.

Cycle 3: Timer

Timer keeps a strong reference to its target or block. The run loop keeps the timer.

final class Clock {
    var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }

    func tick() { }
    func stop() { timer?.invalidate() }   // also needed!
}

[weak self] is not enough alone. A repeating timer keeps running until you call invalidate(). A good place is when the screen disappears.

Cycle 4: Combine subscriptions

final class SearchViewModel {
    @Published var query = ""
    private var cancellables = Set<AnyCancellable>()

    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .sink { [weak self] text in self?.search(text) }
            .store(in: &cancellables)    // self -> cancellables -> sink -> self
    }

    func search(_ text: String) { }
}

Without [weak self], the sink closure holds self and self holds the sink.

Cycle 5: long-running Tasks

final class FeedViewModel {
    private var task: Task<Void, Never>?

    func start(updates: AsyncStream<String>) {
        task = Task { [weak self] in
            for await item in updates {
                self?.handle(item)
            }
        }
    }

    func handle(_ item: String) { }
    func stop() { task?.cancel() }
}

A Task holds what it captures until it ends. A short task is fine. A task that loops forever keeps self alive forever. Use [weak self] and cancel it.

Think about ownership. The owner holds a strong reference. The child points back weakly.

RelationshipStrong sideWeak side
View controller and its viewVC owns view—
Parent and childParent → childChild → parent
Object and its delegateDelegate owner → objectObject → delegate
Object and stored closureObject → closureClosure → [weak self]

How to detect cycles

  • Add deinit { print("freed") } and check it prints when you leave a screen.
  • Use Xcode's Debug Memory Graph button. Leaked objects show a purple warning.
  • Use the Leaks instrument in Instruments.
  • In unit tests, check that a weak reference becomes nil after the object should be gone.

Common follow-up questions

  • Does DispatchQueue.main.async { self.x = 1 } cause a cycle? No. The queue does not belong to self. The closure is released after it runs.
  • Do SwiftUI views cause retain cycles? Views are structs, so they cannot. But your @Observable classes and their closures still can.
  • Are cycles possible with NotificationCenter? Yes, with the block API. The center keeps the block, and the block may capture self. Remove the observer or use [weak self].
  • Can a cycle involve three objects? Yes. A → B → C → A is also a cycle. The memory graph helps find these.
  • Is unowned a valid fix? Yes, if the other object always lives longer. Otherwise use weak to avoid crashes.