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 (3)
What’s the sneakiest SwiftUI memory leak you’ve ever chased?
For me, the wildest one was a single misplaced @StateObject buried inside a child view — it silently duplicated a full view model every time I navigated. Took 10–15 loops before the app felt ‘sticky’ enough for me to notice. Curious what traps other people have run into.
I do not understand your second point.
“Task captures strongly by default” is the case for every reference type instance in Swift.
If you want to trigger a task from a view you should use .task, and you can provide an id parameter if you want the task to run again when something changes.
No need for weak.
Correct!
Taskcaptures strongly like any closure.My intention was to highlight how this becomes a hidden leak source in SwiftUI when tasks outlive the view lifecycle, a
Task {}created inside.onChangeisn’t tied to the SwiftUI lifecycle, so the strong capture can keep the view model alive longer than expected.Using .task(id:) is definitely cleaner, but when Task is used inside callbacks, weak-capturing avoids that unexpected retention(explicit capture semantics help avoid leaks in async flows that escape the view).
So the point was not that Task behaves differently, but that it’s an easy place for leaks to hide in SwiftUI apps.