Skip to content
Commitwise
Menu
iOS Development

Memory leaks and retain cycles

Find and fix the retain cycles that keep iOS screens alive after they close, in closures, Combine subscriptions, timers, notification observers and long-running Tasks, and catch leaks with Xcode's memory graph, Instruments and a one-line test.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Open the product screen, go back, open it again. Do it twenty times. In a leaking app there are now twenty product screens in memory, each with its view model, its images, and its timer still firing network requests in the background. Nothing crashes right away; the app just gets slower, uses more battery, and is eventually killed by iOS for using too much memory, usually while the user is in the middle of something.

Swift frees objects with ARC, the moment nothing refers to them. A retain cycleRetain cycleTwo or more objects holding strong references to each other, so none of them is ever freed.Open in glossary → is when objects refer to each other in a loop, so none of them ever gets there. The Memory: ARC vs garbage collection lesson explains the mechanism; this one is about where cycles hide in real iOS code, and how to find them.

New to some of these? Open what you need, or skip ahead.

Basics:What is ARC?

ARC (Automatic Reference Counting) keeps a count for every class instance: how many strong references point to it. A let or var holding an object is a strong reference by default. When the count drops to zero, Swift frees the object immediately and calls its deinit.

A weak reference doesn’t count. When the object is freed, a weak var becomes nil by itself.

Basics:What does a closure capture?

A closure is a block of code you can store and run later, like { self.reload() }. To run later, it keeps everything it uses alive: if it mentions self, it holds a strong reference to self for as long as the closure itself exists. A capture list such as [weak self] changes that.

Part Not started

Closures that hold on

Two people each holding the other’s leash can’t walk away from each other. A retain cycle in iOS is almost always this pair: an object keeps a closure (as a property, or in something it owns), and the closure uses self, so it keeps the object. Neither count ever reaches zero.

Closures you don’t keep are fine: map { $0 * 2 }, withAnimation { … }, a completion handler that runs once and is thrown away. They release self when they finish.

A cycle needs a closed path of strong references: self → (property, cancellables set, observer token, timer, Task handle) → closure → self. Escaping closures stored by something self owns are the risk; non-escaping closures and one-shot escaping closures (a network completion that runs once) only extend the lifetime. [weak self] breaks the edge from closure to self; [unowned self] does too but crashes if the closure outlives self, so reserve it for lifetimes the type system or structure guarantees. Capturing only what’s needed ([service]) avoids capturing self at all.

final class ProductViewController: UIViewController {
    private let viewModel: ProductViewModel
    private var cancellables = Set<AnyCancellable>()
    private var observer: NSObjectProtocol?

    override func viewDidLoad() {
        super.viewDidLoad()

        // 1. A callback stored by an object self owns
        viewModel.onChange = { [weak self] state in
            self?.render(state)
        }

        // 2. A Combine subscription stored in self's own set
        viewModel.$price
            .receive(on: DispatchQueue.main)
            .sink { [weak self] price in self?.priceLabel.text = price.formatted() }
            .store(in: &cancellables)

        // 3. A block-based notification observer
        observer = NotificationCenter.default.addObserver(
            forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
        ) { [weak self] _ in
            self?.viewModel.refresh()
        }
    }

    deinit {
        if let observer { NotificationCenter.default.removeObserver(observer) }
        print("ProductViewController deinit")          // proof that it was freed
    }
}
  • In all three, the view controller owns something (the view model, the cancellables, the observer token) that keeps the closure. Without [weak self], the closure would keep the view controller, closing the loop.
  • self?. calls the method only if the view controller still exists.
  • print in deinit is the simplest leak detector: leave a screen, and the message should appear. If it doesn’t, something still holds it.
  • For Combine, .assign(to: \.text, on: priceLabel) is fine, but .assign(to: \.title, on: self) keeps self strongly; use .assign(to: &$property) or sink with [weak self].
Spot the bugA screen that never goes away

Tap the line with the bug.

final class CartViewController: UIViewController {
    private let cart: CartModel
    private let tableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()
        cart.onItemsChanged = {
            self.tableView.reloadData()
            self.updateTotal()
        }
    }
}

Predict the output

A view controller runs DispatchQueue.main.asyncAfter(deadline: .now() + 10) { self.saveDraft() } and the user closes the screen after 2 seconds. When is the view controller freed?

Delegates, timers and other strong holders

  • Delegates: declare weak var delegate: SomeDelegate? (the protocol must be AnyObject-constrained). A strong delegate is a cycle whenever the delegate owns the delegating object, which is almost always.
  • Timer: a scheduled timer is retained by the run loop and retains its target or block until invalidated. [weak self] in the block isn’t enough to stop it firing; invalidate it in viewWillDisappear or when the owner goes away (and note deinit won’t run while the target-based API holds you).
  • CADisplayLink retains its target the same way; use a weak proxy.
  • URLSession with a delegate retains the delegate strongly until invalidateAndCancel() or finishTasksAndInvalidate().
  • SwiftUI: views are structs and can’t form cycles, but @Observable or ObservableObject models can: a model that stores a closure capturing itself, or two models referencing each other strongly.
  • Cycle = closed path of strong refs; the usual path is self → owned storage (callback, cancellables, observer token, timer) → escaping closure → self.
  • [weak self] in stored closures; [unowned self] only with guaranteed lifetimes; capture specific dependencies instead of self.
  • One-shot escaping closures delay release but don’t cycle.
  • Weak delegates; invalidate timers and display links; URLSession delegates are retained until invalidation; assign(to:on: self) retains self.

Check this part

  1. Which closure needs [weak self]?

  2. How can you quickly check that a screen is freed?

  3. Why is [weak self] in a repeating Timer block not enough?

The most common iOS cycle is an object that stores a closure which captures the object: callbacks, Combine sinks stored in the object's own cancellables, block-based observers. Capture [weak self] in closures the object keeps; non-escaping closures and short-lived ones are fine.

Part Not started

Tasks that never end

Swift Concurrency adds a new place to hold self: a Task. A task that loads data once and finishes is harmless; it releases self when it’s done. A task that never finishes, like a loop that listens for updates forever, keeps whatever it captured forever too, unless something cancels it.

Unstructured Task { } captures strongly and implicitly (no self. required in closures passed to Task in Swift 5.8+ when self is captured), and holds captures until completion, regardless of whether anyone stores the handle. [weak self] only helps if self is re-checked per iteration; guard let self at the top of the task pins it for the entire body. Prefer structured lifetimes: SwiftUI’s .task (cancelled when the view disappears), task groups, or store the handle and cancel it in a lifecycle method. deinit can’t be relied on to cancel a task that holds self, since deinit never runs.

@MainActor
final class ChatViewModel {
    private(set) var messages: [Message] = []
    private var listenTask: Task<Void, Never>?
    private let service: ChatService

    init(service: ChatService) { self.service = service }

    func start() {
        listenTask = Task { [weak self, service] in
            for await message in service.messages() {
                guard let self else { return }      // checked per message, not pinned for the loop
                self.messages.append(message)
            }
        }
    }

    func stop() {
        listenTask?.cancel()
        listenTask = nil
    }
}

// In SwiftUI, let the view own the lifetime instead:
struct ChatView: View {
    @State private var model: ChatModel
    var body: some View {
        MessageList(messages: model.messages)
            .task { await model.listen() }          // cancelled automatically when the view disappears
    }
}
  • guard let self inside the loop takes a strong reference for one message at a time. When the screen is gone, the next message finds self == nil and the task ends.
  • stop() cancels the task explicitly; call it when the screen closes.
  • In SwiftUI, .task { } is the easiest option: it starts when the view appears and is cancelled when it disappears, so the loop ends by itself.

Production puzzles

Spot the bug[weak self], and still leaking

Tap the line with the bug.

@MainActor
final class PricesViewModel: ObservableObject {
    @Published var prices: [String: Decimal] = [:]
    private var task: Task<Void, Never>?

    func start(feed: PriceFeed) {
        task = Task { [weak self] in
            guard let self else { return }
            for await update in feed.updates() {
                self.prices[update.symbol] = update.price
            }
        }
    }
}

Other concurrency holders

  • AsyncStream continuations stored by a producer keep the stream (and anything its onTermination closure captures) alive; finish them on teardown.
  • Actors retain what they’re given; an actor-held array of callbacks capturing view models is the actor-era version of the static listener list.
  • Detached and fire-and-forget tasks have no owner to cancel them; avoid them for anything long-running.
  • Tasks hold captures until completion, stored or not. guard let self at the top pins self for the whole body: re-check per iteration.
  • Cancel long-running tasks explicitly (not in deinit, which won’t run), or prefer .task and structured concurrency.
  • Finish AsyncStream continuations and clean up in onTermination; watch actor-held callbacks; avoid detached long-running tasks.

Check this part

  1. What does SwiftUI's .task modifier do when the view disappears?

  2. Why can't deinit cancel a task that captured self strongly?

A Task keeps everything it captures until it finishes. A long-running Task (a for-await loop over a stream, a polling loop) stored in self is a cycle even with [weak self], once guard let self holds self for the loop. Cancel such tasks when the owner goes away, or use SwiftUI's .task modifier.

Part Not started

Finding leaks

Leaks are found the same way every time:

  1. Repeat an action that should leave nothing behind: open a screen and go back, five times.
  2. Look at what’s still in memory. In Xcode, press the Debug Memory Graph button while the app runs. The left panel lists every live object by class; if there are five ProductViewControllers, they leaked.
  3. Follow the path. Select one: the graph shows which objects point to it. The arrow you didn’t expect (a closure, a timer, a cart) is the cycle.
  4. Fix and repeat until the count stays at zero after leaving the screen.

The memory graph debugger shows live instances and the retaining paths (enable Malloc Stack Logging in the scheme to see where each was allocated). Instruments’ Leaks detects unreachable cycles; Allocations with generation marks (“Mark Generation” before and after a repeated action) catches abandoned memory that is still reachable, which Leaks doesn’t flag, such as caches that only grow or observers held by singletons. In CI, a weak-reference assertion in XCTest or Swift Testing catches regressions for the screens you care about.

extension XCTestCase {
    func assertNoLeak(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) {
        addTeardownBlock { [weak instance] in
            XCTAssertNil(instance, "Instance should have been deallocated. Potential retain cycle.",
                         file: file, line: line)
        }
    }
}

final class CartViewControllerTests: XCTestCase {
    @MainActor
    func testCartScreenIsReleased() {
        let cart = CartModel()
        let sut = CartViewController(cart: cart)
        assertNoLeak(sut)
        sut.loadViewIfNeeded()          // runs viewDidLoad, where the closures are set up
    }
}
  • addTeardownBlock runs after the test, when every local variable is gone. If the weak reference isn’t nil, something still holds the view controller, and the test fails.
  • The test keeps the bug from coming back: the CartViewController cycle above fails this test until [weak self] is added.
Exercisemedium

Hunt the leak

After opening and closing the order detail screen five times, the memory graph shows five OrderDetailViewController instances. Selecting one shows this path: NotificationCenter → __NSObserver → closure → OrderDetailViewController. Here’s the setup code. Find the cycle and fix it.

override func viewDidLoad() {
    super.viewDidLoad()
    NotificationCenter.default.addObserver(
        forName: .orderUpdated, object: nil, queue: .main
    ) { notification in
        guard let id = notification.object as? Order.ID, id == self.orderID else { return }
        self.reload()
    }
}
›

The block-based API returns a token, and NotificationCenter keeps the closure until that token is removed. Who removes it here?

SolutionTry it yourself first

Strictly speaking it isn’t a cycle: the view controller doesn’t keep the token. But NotificationCenter, which lives forever, keeps the observer and its closure until removeObserver is called, and the closure keeps self strongly. Nobody removes it, so every screen ever opened stays in memory and keeps reloading on every order update.

Capture weakly and remove the observer:

private var orderObserver: NSObjectProtocol?

override func viewDidLoad() {
    super.viewDidLoad()
    orderObserver = NotificationCenter.default.addObserver(
        forName: .orderUpdated, object: nil, queue: .main
    ) { [weak self] notification in
        guard let self, let id = notification.object as? Order.ID, id == orderID else { return }
        reload()
    }
}

deinit {
    if let orderObserver { NotificationCenter.default.removeObserver(orderObserver) }
}

With [weak self], deinit can run, and it removes the observer. Alternatives: NotificationCenter.default.notifications(named:) in a .task or a stored task cancelled on disappear, or a Combine publisher(for:) stored in cancellables, which cancels when the view controller is freed.

Production puzzles

Predict the output

You run Instruments' Leaks template while opening and closing a screen 20 times. Leaks reports nothing, yet memory grows by 30 MB and the memory graph shows 20 instances of the screen's view model. How can both be true?

Memory beyond cycles

  • Images dominate most apps’ memory: decode at display size (UIGraphicsImageRenderer or CGImageSourceCreateThumbnailAtIndex), and use a cache with a cost limit (NSCache) that responds to memory warnings.
  • Unbounded caches in singletons are leaks by another name; give them limits.
  • Jetsam: iOS kills apps above a per-device memory limit without a crash log in the usual place; check Xcode Organizer’s terminations and MetricKit’s MXAppExitMetric.
  • Repeat an action, then Debug Memory Graph (with Malloc Stack Logging) for live instances and retaining paths.
  • Instruments Leaks finds unreachable cycles; Allocations with generations finds abandoned-but-reachable memory (singleton observer lists, growing caches).
  • Weak-reference assertions in teardown (addTeardownBlock) keep regressions out of CI.
  • Images and unbounded caches are the big memory consumers; watch jetsam terminations via Organizer and MetricKit.

Check this part

  1. After closing a screen five times, the memory graph shows five of its view controllers. What does that mean?

  2. Why doesn't Instruments' Leaks find objects held by a singleton's observer list?

Reproduce by opening and closing a screen several times, then use Xcode's Debug Memory Graph to see instances that should be gone and the reference path holding them. Instruments' Leaks and Allocations confirm and measure; a weak-reference check in tests keeps leaks from coming back.

Key takeaways

  • A retain cycle is an object that keeps a closure (or delegate, or timer) that keeps the object.
  • Use [weak self] in closures that are stored; one-shot closures only delay release.
  • Long-running Tasks must be cancelled, or owned by SwiftUI’s .task.
  • Find leaks by repeating an action and checking the memory graph; add a weak-reference test.
  • Trace the strong path: owned storage → escaping closure → self.
  • guard let self per iteration in long tasks; cancel explicitly, not in deinit.
  • Leaks finds cycles; Allocations generations find abandoned memory.
  • Weak delegates, invalidated timers, bounded caches, and leak assertions in CI.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.