Finding memory leaks in iOS apps without wasting a day in Instruments
Memory leaks are not solved by staring at Instruments until the graph confesses. Start with ownership, reproduce the leak, prove deallocation, then use Instruments for the cases that actually deserve it.
Memory leaks in iOS apps are rarely mysterious.
They are usually boring ownership bugs wearing a trench coat.
A view model holds a closure. The closure captures self. A task keeps running after the screen disappears. A notification observer survives because nobody removed it. A timer repeats forever because apparently time itself needed a retain cycle.
Then somebody opens Instruments, clicks around for forty minutes, and declares the app haunted.
Do not start there.
Instruments is useful. It is not a substitute for having a mental model of ownership. If you do not know what should die, when it should die, and who is keeping it alive, the tool will just give you a prettier fog machine.
1. First decide what should deallocate
Before debugging anything, name the object that should disappear.
Usually it is one of these:
- a view model after its screen is dismissed
- a coordinator after a flow ends
- a service scoped to a feature
- a closure-backed adapter
- a
Taskowned by UI state - a cache entry after eviction
If you cannot point to the object, you are not debugging a leak. You are admiring memory growth.
Those are different problems.
A leak means an object has no business being alive but still is. Memory growth might be caching, image decoding, SwiftUI retaining view graph history for a moment, or the system doing system things. Not every upward line is a crime scene.
Start with one claim:
ProfileViewModelshould deallocate after the profile screen is dismissed.
Now you have something testable.
2. Add deinit breadcrumbs before touching Instruments
The cheapest leak detector is still deinit.
final class ProfileViewModel {
deinit {
print("ProfileViewModel deinit")
}
}
Yes, print is crude. This is local debugging, not production observability. Use OSLog if you want filtering. The point is to prove whether the object dies.
A better pattern is a tiny helper you can drop into suspicious objects:
import os
private let logger = Logger(subsystem: "dev.vburojevic.app", category: "lifetime")
final class LifetimeProbe {
private let name: String
init(_ name: String) {
self.name = name
logger.debug("init: \(name)")
}
deinit {
logger.debug("deinit: \(name)")
}
}
Then attach it:
@MainActor
final class ProfileViewModel {
private let lifetime = LifetimeProbe("ProfileViewModel")
}
Now reproduce the flow:
- open the screen
- close the screen
- wait one run loop
- check whether
deinithappened
If it did, stop. You do not have a leak in that object.
If it did not, now you have a real target.
3. Reproduce the leak in a small loop
A leak you can trigger once is annoying.
A leak you can trigger ten times is evidence.
Build a tiny reproduction loop:
- push the screen
- perform the action that starts work
- dismiss the screen
- repeat five to ten times
Watch memory and lifetime logs. If one old instance survives every cycle, you have a leak. If memory rises and then settles, you may be looking at caching or allocator behavior.
Do not debug from one navigation pass. SwiftUI, UIKit, URLSession, image decoding, and autorelease pools can all hold temporary objects longer than your patience.
Patience is not a profiler.
4. The usual suspects are still the usual suspects
Most iOS leaks come from a depressingly small list.
Strong captures in escaping closures
The classic:
final class SearchViewModel {
private let service: SearchService
func bind() {
service.onResults = { results in
self.results = results
}
}
}
If service owns the closure and the closure owns self, the view model is not leaving. It has found rent-controlled housing.
Use a weak capture when the closure can outlive the owner:
service.onResults = { [weak self] results in
self?.results = results
}
But do not sprinkle [weak self] like seasoning. If the closure is short-lived and owned by the object itself, weak capture may hide a lifecycle bug instead of fixing it.
The useful question is:
Can this closure outlive the object it references?
If yes, capture weakly or redesign the ownership.
Timers and display links
Repeating timers retain their target or closure until invalidated.
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
Weak capture helps, but invalidation is still part of ownership:
deinit {
timer?.invalidate()
}
For CADisplayLink, be even more suspicious. A display link that survives a dismissed screen is a tiny battery-powered leak generator.
NotificationCenter observers
Block-based notification observers return a token. If you keep the token, you own the observation. If the block captures self, you may also own your own prison.
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.refresh()
}
Remove it when the owner dies:
deinit {
if let observer {
NotificationCenter.default.removeObserver(observer)
}
}
Modern APIs reduce some footguns. They do not delete ownership.
Combine subscriptions
Combine made retain cycles feel modern.
publisher
.sink { value in
self.value = value
}
.store(in: &cancellables)
self owns cancellables. The cancellable owns the subscription. The subscription owns the closure. The closure owns self.
Congratulations. You built a ring.
Usually:
publisher
.sink { [weak self] value in
self?.value = value
}
.store(in: &cancellables)
Also cancel subscriptions when the feature ends if the owner is longer-lived than the screen.
Async tasks
Swift concurrency did not remove leaks. It gave them nicer syntax.
Task {
for await event in events {
self.handle(event)
}
}
If that task never finishes and captures self, the owner is retained forever.
Prefer storing and canceling the task:
private var eventsTask: Task<Void, Never>?
func start() {
eventsTask = Task { [weak self] in
for await event in events {
guard let self else { return }
await self.handle(event)
}
}
}
deinit {
eventsTask?.cancel()
}
The key is not the [weak self] incantation. The key is that the task has an owner and a cancellation point.
Unstructured tasks without ownership are just background leaks with better branding.
5. SwiftUI leaks are often model lifetime bugs
SwiftUI views are values. Your models are not.
When a SwiftUI screen leaks, check the property wrapper first.
Use @StateObject when the view creates and owns the model:
struct ProfileScreen: View {
@StateObject private var model: ProfileViewModel
init(userID: User.ID) {
_model = StateObject(wrappedValue: ProfileViewModel(userID: userID))
}
var body: some View {
ProfileContent(model: model)
}
}
Use @ObservedObject when the parent owns it.
Use @EnvironmentObject when shared app-level ownership is intentional.
A surprising number of “SwiftUI memory leaks” are actually models accidentally promoted to app lifetime through environment, singletons, or navigation state that never gets cleared.
If a screen model lives in a global router array after dismissal, Instruments is not going to fix your architecture. It will merely document the archaeology.
6. Use Xcode’s memory graph for the first real clue
Once deinit proves the object survives, use the memory graph debugger.
Run the app. Reproduce the leak. Click Debug Memory Graph.
Search for the leaked class name.
What you want is the retaining path:
- what object owns the leaked instance?
- what closure or collection is in the path?
- is the owner expected to be alive?
- is there a cycle?
Do not start by browsing every object in the heap. That is how you lose an afternoon and gain nothing except resentment.
Search for the specific object. Inspect retainers. Follow the path.
If you see something like:
ProfileViewModel
└── closure context
└── SearchService.onResults
└── SearchService
You have your answer.
If you see navigation state, coordinator arrays, task storage, or a cache, the bug is not a mysterious leak. It is ownership doing exactly what you told it to do.
Unfortunately, computers are loyal like that.
7. Instruments comes after you have a hypothesis
Open Instruments when you know what question you are asking.
Good questions:
- does
ProfileViewModelcount increase after each presentation? - which allocations are responsible for the steady growth?
- does a specific image pipeline retain decoded buffers?
- do task objects accumulate after leaving the screen?
- does memory return after forcing a cache eviction?
Bad questions:
- why is memory high?
- is my app leaking?
- what is wrong?
Instruments can answer specific questions. It cannot rescue a vague one.
For leaks, start with:
- Allocations to track instance counts and growth
- Leaks to detect unreachable leaked memory
- Memory Graph for retaining paths
- Signposts if the leak relates to a repeated flow
The Leaks instrument is not magic. It finds memory that is no longer reachable but still allocated. Retain cycles are often still reachable, so Leaks may not flag them. That does not mean your view model should still be alive.
This is why deinit and retaining paths matter.
8. Separate leaks from caches
Caching looks like leaking when nobody wrote down the cache policy.
Image loaders, URL caches, database layers, formatters, decoders, layout engines, and SwiftUI internals may keep memory around because reuse is faster than churn.
A cache is fine if it has:
- a maximum cost
- an eviction policy
- a memory warning response
- observability for entry count and total size
- a reason to exist beyond vibes
A leak has none of that. It is just an object that forgot to leave.
If memory grows during heavy image scrolling, ask:
- are decoded images cached without a cost limit?
- are full-resolution images kept for thumbnails?
- does the cache clear on memory warning?
- are in-flight requests canceled when cells disappear?
- does memory stabilize after scrolling stops?
If the answer is “we store every image in a dictionary forever,” that is not a leak. That is a product decision made accidentally.
Those are the expensive ones.
9. Add lifetime tests for the objects that matter
Some leaks can be caught with unit tests.
Not all. Enough to be useful.
func testProfileViewModelDeallocatesAfterUse() {
weak var weakModel: ProfileViewModel?
autoreleasepool {
let service = MockSearchService()
var model: ProfileViewModel? = ProfileViewModel(service: service)
weakModel = model
model?.start()
model?.stop()
model = nil
}
XCTAssertNil(weakModel)
}
This is not a replacement for runtime testing. It is a tripwire for obvious retain cycles in important objects.
It works best for:
- view models
- coordinators
- service adapters
- observers
- subscription owners
- cache entries
It works poorly for pure SwiftUI view graphs, UIKit controller transitions, and anything the framework legitimately retains for a short period.
Use it where ownership is yours.
10. Cancel work when the UI goes away
A lot of memory leaks are really lifecycle leaks.
The screen disappears, but the work keeps running:
- polling tasks
- streaming
AsyncSequenceloops - Combine pipelines
- upload progress observers
- location updates
- Bluetooth scans
- notification observers
If the UI owns the work, cancel it on disappearance or deallocation.
@MainActor
final class SearchViewModel {
private var searchTask: Task<Void, Never>?
func search(query: String) {
searchTask?.cancel()
searchTask = Task { [weak self] in
guard let self else { return }
let results = await self.service.search(query)
guard !Task.isCancelled else { return }
self.results = results
}
}
func stop() {
searchTask?.cancel()
searchTask = nil
}
deinit {
searchTask?.cancel()
}
}
If the work is app-level, move it out of the screen model and make that ownership explicit.
What you should not do is let a dismissed screen remain alive because a background stream still wants someone to talk to. That is not architecture. That is a hostage situation.
11. The practical leak-hunting loop
This is the sequence I would use before sacrificing an afternoon to Instruments:
- name the object that should die
- add a
deinitbreadcrumb or lifetime probe - reproduce the flow repeatedly
- confirm the object survives
- check the usual suspects: closures, timers, observers, Combine, tasks
- open the memory graph and inspect retaining paths
- form a hypothesis
- use Instruments to measure that specific hypothesis
- fix ownership, not symptoms
- add a lifetime test if the object is important enough
This loop is boring.
Good.
Debugging should become boring when the system is understood. Drama is what happens when nobody knows who owns what.
12. The fix is usually ownership, not tooling
The best memory leak fix is rarely clever.
It is usually one of these:
- make a closure capture weakly
- cancel a task
- invalidate a timer
- remove an observer
- clear navigation state
- put a cache behind a real eviction policy
- move long-lived work into a long-lived owner
- stop making singletons responsible for feature-scoped state
None of this requires mystical Instruments stamina.
It requires discipline about lifetimes.
Know what owns the object. Know when that ownership ends. Prove the object deallocates. Use tools when the retaining path is not obvious.
The memory graph is allowed to help. It should not be the first person in the room with a plan.