Home / Blog / Hunting memory leaks with Instruments: three real scenarios

Hunting memory leaks with Instruments: three real scenarios

Memory leaks are the sneakiest problem in iOS apps. Real examples using Xcode Instruments' Leaks and Allocations tools.

Memory leaks surface in apps that stay open for a long time in production. A user wanders through the app for 30 minutes, RAM creeps up, the app slows down or crashes.

They are hard to debug because reproducing them needs a specific user flow. Instruments is the way to hunt them. Here are three real scenarios, each showing a different leak pattern.

Getting familiar with Instruments

In Xcode, Product > Profile (Cmd+I). It builds a release build of the app and opens Instruments.

The critical tools:

  • Leaks: detects actual memory leaks (reference cycles, etc).
  • Allocations: which objects are holding memory, live count.
  • Time Profiler: which function is eating CPU.
  • Energy Log: battery impact.
  • Core Animation: UI performance.

For memory leaks the Leaks + Allocations combination is the most powerful.

Scenario 1: closure retain cycle

In Dentii a user would navigate from the TabBar to a view. Then tap back, and the view would not deallocate. Every navigation leaked one instance.

In Instruments:

  1. Opened Leaks.
  2. Navigated forward and back (5 times).
  3. The Leaks tool showed 5 DashboardViewController instances.

Allocations cycle view:

DashboardViewController
  └─ self.dataFetcher.callback
       └─ captures self (strong reference)

Retain cycle.

In code:

class DashboardViewController: UIViewController {
    let dataFetcher = DataFetcher()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        dataFetcher.callback = { result in
            self.handleResult(result)  // BUG: strong capture of self
        }
    }
}

The controller holds dataFetcher, dataFetcher holds self through the callback. Cycle.

Fix:

dataFetcher.callback = { [weak self] result in
    self?.handleResult(result)
}

[weak self] breaks the cycle. The controller can deallocate.

Prevention pattern: make [weak self] the default in every escaping closure. Only use [unowned self] when the closure is guaranteed to finish before the view controller.

Scenario 2: repeating timer with no invalidate

In Snoozio the app keeps running when the user sends it to the background. Eight hours later the user opens it again and memory is enormous.

Instruments Allocations:

  1. At launch: 50MB memory.
  2. After 30 minutes: 150MB.
  3. After an hour: 300MB.

Linear growth. Timer problem.

In code:

class SleepTracker {
    func start() {
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.checkSleepState()
        }
    }
}

The timer holds self, self does not hold the timer, but the timer is active on the run loop so it is never deallocated. Every start() call creates a new timer.

Fix:

class SleepTracker {
    private var timer: Timer?
    
    func start() {
        stop()
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.checkSleepState()
        }
    }
    
    func stop() {
        timer?.invalidate()
        timer = nil
    }
    
    deinit {
        stop()
    }
}

Hold the timer reference, invalidate it, and the weak self kills the cycle.

Prevention: scheduled timers always end with invalidate(). Guarantee cleanup in deinit.

Scenario 3: observer registration with no removal

In CVCrafter every screen registered a keyboard notification observer. Some screens never removed them on close.

Instruments Leaks did not flag this directly (technically not a leak) but Allocations showed the memory growth.

I checked the observer count:

do {
    let token = NotificationCenter.default.addObserver(
        forName: UIResponder.keyboardWillShowNotification,
        object: nil,
        queue: .main
    ) { notification in ... }
}

I was not storing the token, so I had no way to remove it. Every screen added a keyboard observer, none ever got removed.

Fix:

class FormViewController: UIViewController {
    private var observers: [NSObjectProtocol] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let token = NotificationCenter.default.addObserver(
            forName: UIResponder.keyboardWillShowNotification,
            object: nil,
            queue: .main
        ) { [weak self] notification in
            self?.handleKeyboard(notification)
        }
        observers.append(token)
    }
    
    deinit {
        observers.forEach { NotificationCenter.default.removeObserver($0) }
    }
}

Store the token, remove in deinit. Clean observer lifecycle.

Alternative: with Swift 5.5+ the Notification Center async sequence:

for await notification in NotificationCenter.default.notifications(named: .keyboard...) {
    // observation stops automatically when the task is cancelled
}

Instruments workflow

My approach:

Step 1: take an Allocations profile.

  1. Launch the app.
  2. Record the baseline memory.
  3. Run the suspected flow (navigate, scroll, whatever).
  4. Return to the initial state.
  5. Check the “Persistent allocation” counter.

Ideal: the counter goes back to baseline. If not, you have a leak.

Step 2: run the Leaks tool.

Detects active leaks and shows the stack trace.

Step 3: visualise the cycle.

Filter by object type in Allocations. Retain cycle graph view.

Step 4: fix.

Weak/unowned, invalidation, removal.

Step 5: re-profile.

Confirm the fix works.

Leak detection automation in Xcode

Instruments is manual, but Xcode has tools too:

Address Sanitizer (ASan): memory corruption, buffer overflow.

Thread Sanitizer (TSan): data races, concurrent access.

Undefined Behavior Sanitizer (UBSan): undefined behaviour.

Malloc Stack Logging: a stack trace for every allocation.

Turn these sanitizers on in the Debug scheme. They catch leaks during normal testing, no Instruments needed.

SwiftUI-specific leaks

SwiftUI has a different memory model from UIKit. Views are structs, state is @StateObject or @ObservableObject.

Common SwiftUI leak:

struct ContentView: View {
    @StateObject var viewModel: ViewModel = {
        let vm = ViewModel()
        vm.onUpdate = {
            // captures vm in closure
            vm.refresh()
        }
        return vm
    }()
}

The ViewModel captures itself in its own callback.

Fix: the [weak vm] pattern.

It looks like SwiftUI’s @State objects do not preserve identity across re-renders, but in reality @StateObject or @State keep them persistent.

The real impact of leaks

What leaks actually cost you:

  • Memory pressure: once you pass 500MB, iOS can kill the app.
  • Battery drain: cleanup attempts on leaked objects.
  • Slow performance: zombie objects behaving like a pending garbage collection.
  • Crashes under low memory conditions.

Leaks are silently impactful in production. The crash reporter shows “terminated due to memory pressure”.

Testing strategy

Test suite for memory leaks:

Unit tests: the weak reference pattern in XCTest. Create an object, let it go out of scope, the weak reference should become nil.

func testViewModelDeallocates() {
    weak var viewModel: ViewModel?
    
    autoreleasepool {
        let vm = ViewModel()
        viewModel = vm
        vm.start()
    }
    
    XCTAssertNil(viewModel, "ViewModel should be deallocated")
}

Integration tests: run a navigation flow, check memory. Before and after comparison.

Instruments as part of CI: advanced. Automated profiling for release candidates.

When to profile

At project milestones:

  • Pre-release: profile the release build in Instruments. Baseline memory, peak memory, leak count.
  • After a major feature: check memory impact of any big new feature.
  • User-reported performance issues: “the app is slow or using too much RAM”.
  • Annual health check: a whole-app performance review once a year.

Takeaway

Memory leaks are the silent enemy of an iOS app. Instruments’ Leaks + Allocations pair is the main tool.

Three common patterns: closure retain cycle, missing timer/observer cleanup, third party SDK lifecycle. Handle those three and you prevent most leaks.

Weak/unowned by default, invalidation discipline, notification removal. These practices make migrations easy.

The ability to use Instruments is a fundamental iOS developer skill. Even one or two deep dive sessions a year has a big impact.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch