DEV Community

Vinayak G Hejib
Vinayak G Hejib

Posted on • Edited 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 (3)

Collapse
 
canoconner801 profile image
Conner Cano

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.

Collapse
 
mathis_gaignet_33ccca0a88 profile image
Mathis Gaignet

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.

Collapse
 
vnayak_hejib profile image
Vinayak G Hejib • Edited

Correct! Task captures 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 .onChange isn’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.