Debugging iOS memory leaks and reference cycles in Xcode

This article focuses on one concrete debugging story: a cat-themed sample app leaks view controllers because a table-view cell keeps a strong back-reference to its parent controller. The article walks through the Xcode memory graph, retained-object inspection, malloc stack logging, and a delegate-based fix that breaks the cycle.

Sample cat-ranking app used to demonstrate the memory leak

The strength of this article is that it teaches memory leaks through one small, inspectable bug instead of abstract ARC theory.

The sample app ranks cats by how naughty they are. The UI is intentionally simple: a table view, a custom cell, and two buttons that move a cat up or down in the list. That small surface area makes the bug easy to reason about when the memory graph starts behaving incorrectly.

Core idea The bug is not an allocation spike by itself. The problem is that memory rises when a view controller is shown, but does not return to the earlier baseline after the controller is dismissed repeatedly.

The sample is a custom UITableViewCell that talks back to its parent list controller.

This article links to the sample project here: mszopensource/MemoryLeakTesting. In the buggy version, cellForRowAt stores the parent controller inside every cell so the up and down buttons can call back into the table logic.

override func tableView(
    _ tableView: UITableView,
    cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "naughtyCell") as! naughtyCell
    let catName = myCats[indexPath.row]
    cell.catNameLabel.text = catName
    cell.catTableView = self
    cell.catID = indexPath.row
    return cell
}
class naughtyCell: UITableViewCell {
    @IBOutlet weak var catNameLabel: UILabel!

    @IBAction func actionMoveUp() {
        catTableView.moveCatUp(forCatNumber: catID)
    }

    @IBAction func actionMoveDown() {
        catTableView.moveCatDown(forCatNumber: catID)
    }

    var catTableView: catList!
    var catID: Int!
}
The sample cat-ranking app where each row can move a cat up or down
The sample app is intentionally tiny so the leak becomes easy to isolate.

Start with the normal memory graph, then look for one suspicious pattern: memory rises on presentation and does not properly fall after dismissal.

The article first uses the standard Xcode debug navigator and the Memory section at the bottom. Before doing anything, the graph stays flat. Opening the next view controller raises the graph, which is expected. The warning sign appears after closing that controller: the graph drops only a little instead of returning near the earlier baseline.

Xcode memory graph staying flat before the app does any work
The baseline is stable before the extra view controller is shown.
Memory usage going up after the second view controller is presented
A rise during presentation is normal because another controller is on screen.
Memory dropping only slightly after dismissing the view controller
The graph should fall back more cleanly when the temporary screen is gone.
Repeatedly opening the screen causing memory to climb further and further
After repeating the flow, the leak becomes obvious instead of subtle.

Use the memory graph debugger to confirm that the controllers and cells are still alive when they should have been released.

The second half of the investigation moves to the dedicated memory graph debugger. Click the memory-graph button, let Xcode pause the app, and inspect the objects that remain in memory. In this article, multiple catList controllers and a large pile of naughtyCell instances are still retained.

The Xcode memory graph debugger button at the bottom of the debug area
The debugger shifts the problem from a chart into a concrete object graph.
Memory graph debugger showing multiple retained catList controllers and many naughtyCell objects
Retained controllers and cells reveal that the dismissed screen was never truly deallocated.
Leak location shown on the right side of Xcode after enabling stack logging
With stack logging enabled, Xcode can narrow the problem to a more specific code path.

If the retained objects alone are not enough, turn on Malloc Stack logging in the scheme diagnostics and rerun the same actions.

This article highlights one especially useful step: go into the run scheme, open the diagnostics tab, and enable Malloc Stack. Then repeat the same open-close cycle, re-open the memory graph debugger, and inspect one of the retained cells again.

Xcode scheme diagnostics with the Malloc Stack option enabled
Turning on allocation stack logging gives the memory tools more context than the default run configuration.

The leak is a classic strong-reference cycle: the cell keeps the controller, and the controller keeps the table and its cells alive.

The article reduces the bug to two lines. The cell stores the controller strongly:

var catTableView: catList!

And the controller assigns itself into each cell:

cell.catTableView = self

Once that happens, ARC can no longer tear the screen down cleanly. The table view keeps its cells, and the cell points back toward the controller that owns the table view. The retained graph never fully collapses after dismissal.

Diagram showing the reference cycle between the controller and the custom cell
The article turns the bug into a simple reference-cycle picture before fixing it.

Replace the controller property with a weak delegate so the cell can send actions upward without owning the controller.

The fix in this article is not to remove communication. It is to change the communication shape. Instead of storing the controller directly, the cell talks to a protocol-backed delegate, and that delegate reference is weak.

protocol catListActionDelegate: AnyObject {
    func moveCatUp(forCatNumber: Int)
    func moveCatDown(forCatNumber: Int)
}
class naughtyCell: UITableViewCell {
    weak var delegate: catListActionDelegate?
    var catID: Int!

    @IBAction func actionMoveUp() {
        delegate?.moveCatUp(forCatNumber: catID)
    }

    @IBAction func actionMoveDown() {
        delegate?.moveCatDown(forCatNumber: catID)
    }
}
class fixedCatList: UITableViewController, catListActionDelegate

The final proof is back in the memory graph. After the change, the graph no longer climbs in the same pathological way when the view controller is opened and closed repeatedly.

Memory graph after the weak delegate fix showing more stable behavior
The fixed graph returns the lesson to its practical end: ARC works again once the cycle is broken.