Memory Leaks in SwiftUI - Where They Hide & How to Catch Them!!
SwiftUI makes UI feel effortles, some SwiftUI views exit the screen. but others cling to RAM like it’s the last samosa 😉
Have you ever observed something highly interactive customization flow—started behaving oddly? Nothing crashes, Nothing obviously lags, but each time we navigate through the flow, the app feels heavier. Not slow… just “sticky,” like the UI was dragging unseen baggage behind it.
I has a similar situation and after 10–15 navigations, Instruments confirmed my suspicion:
A whole family of views, view models, timers, and async tasks were still alive.
Not one or two.
All of them.
It wasn’t a Combine pipeline gone rogue.
It wasn’t AVPlayer.
It wasn’t some UIKit weirdness.
It was a single misused @StateObject buried inside a child view.
The Real Leak That Triggered the Investigation
Inside the main customization screen:
@StateObject var viewModel = BigViewModel()
Perfect.
But deep within a child view:
@StateObject var viewModel: BigViewModel
instead of:
@ObservedObject var viewModel: BigViewModel
That tiny slip created a permanent retention cycle.
Every navigation pushed a new BigViewModel into memory.
None ever left.
The leak was silent until the user repeated the flow enough times for the app to feel heavier.
Real world. Hard to catch. Easy to create.
Where SwiftUI Actually Hides Leaks (Real Examples from Real Apps)
1. Wrong property wrapper (@StateObject vs @ObservedObject)
This is the #1 cause I’ve seen in large SwiftUI codebases.
SwiftUI assumes @StateObject owns the lifecycle.
Child views often live longer than expected (transitions, animations, NavigationStack caching).
One wrong wrapper → immortal view model.
2. Async Tasks that silently retain self
I ran into a bug where a discount recalculation fired on every color change:
.onChange(of: selectedColor) { _ in
Task {
await viewModel.recomputeDiscount()
}
}
Harmless-looking, but Task captures strongly by default.
The recalculation job kept the entire view model—and its whole dependency graph—alive.
The fix:
Task { [weak viewModel] in
await viewModel?.recomputeDiscount()
}
3. Timers that were never cancelled
A carousel featured an auto-scroll timer:
Timer.publish(every: 3, on: .main, in: .common)
.autoconnect()
.sink { _ in self.next() }
Nobody cancelled it.
Every “dead” carousel screen stayed… well… very much alive.
You don’t notice until you have 20 of them.
4. NavigationPath captured in a ViewModel
This one surprised even senior devs:
class NavVM {
var path: NavigationPath
}
The minute you capture the entire NavigationPath, SwiftUI may never free the views, because the path retains every element in it.
If you must observe navigation, observe tokens / IDs, never the full path object.
5. EnvironmentObjects retaining everything
Shared state is great—until it becomes a black hole.
A global theme object held references to:
- feature view models
- child store objects
- even closures that referenced views indirectly
EnvironmentObjects should be pure app state, not dependency containers.
How I Catch Leaks Today (and Sleep Better)
1. Add deinit logs to every ViewModel
deinit { print("deinit: BigViewModel") }
If you don’t see this when popping a screen → it’s leaking.
2. Make [weak self] a reflex
Tasks, closures, timers, Combine pipelines — treat them all as suspects.
3. Audit property wrapper changes during code reviews
Catch wrong wrappers early.
4. Stress-test navigation
Push/pull a flow 20–30 times.
Leaks reveal themselves through repetition.
Final Thought
SwiftUI’s power comes from its declarative nature.
But the lifecycle behind it is not always intuitive—and sometimes, a single misplaced wrapper or async closure can keep entire view trees alive.
Once you know where leaks hide, you’ll start seeing them every week.
And trust me:
Crushing a stubborn leak feels ridiculously satisfying.





Top comments (0)