DEV Community

Vinayak G Hejib
Vinayak G Hejib

Posted on

Memory Leaks in SwiftUI(Real-World Examples)

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()
Enter fullscreen mode Exit fullscreen mode

Perfect.

But deep within a child view:

@StateObject var viewModel: BigViewModel
Enter fullscreen mode Exit fullscreen mode

instead of:

@ObservedObject var viewModel: BigViewModel
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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() }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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") }
Enter fullscreen mode Exit fullscreen mode

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)