DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Memory Management & Retain Cycle Pitfalls (Production Guide)

SwiftUI hides a lot of memory complexity — until it doesn’t.

At scale, teams run into:

  • ViewModels that never deallocate
  • Tasks that keep running forever
  • Navigation stacks leaking memory
  • EnvironmentObjects retaining entire graphs
  • Subtle retain cycles caused by closures
  • Performance degradation after long usage

This post explains how memory actually works in SwiftUI, where leaks come from, and how to design leak-free, production-safe architecture.


🧠 The Core Mental Model

Views are value types.

Objects are reference types.

SwiftUI recreates views constantly.

Memory issues almost always come from objects you own, not views.


🧱 1. ViewModels: Who Owns Them?

❌ Common mistake

struct Screen: View {
    @StateObject var vm = ViewModel()
}
Enter fullscreen mode Exit fullscreen mode

This works — but Screen now owns the ViewModel.

If the screen never leaves the hierarchy, the ViewModel never deallocates.

✅ Correct ownership

@StateObject private var vm = ViewModel()
Enter fullscreen mode Exit fullscreen mode

Ownership is explicit and scoped.


🔄 2. Navigation Is the #1 Leak Source

Navigation stacks retain:

  • the view
  • the ViewModel
  • all captured dependencies

If a ViewModel references:

  • navigation state
  • global services
  • closures capturing self

…it may never be released.

Rule
📌 Navigation path owns memory.

If it stays in the path, it stays alive.


🧵 3. Retain Cycles via Closures

Classic cycle:

class ViewModel {
    var onUpdate: (() -> Void)?

    func bind() {
        onUpdate = {
            self.doSomething()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a retain cycle.

Fix

onUpdate = { [weak self] in
    self?.doSomething()
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI does not protect you from this.


⏳ 4. Async Tasks That Never Die

This is extremely common:

.task {
    await loadData()
}
Enter fullscreen mode Exit fullscreen mode

If loadData():

  • loops
  • awaits long-lived tasks
  • never completes

The task retains the view model.


✅ Safe pattern

.task {
    await loadData()
}
.onDisappear {
    cancelTasks()
}
Enter fullscreen mode Exit fullscreen mode

Or:

Task { [weak self] in
    await self?.loadData()
}
Enter fullscreen mode Exit fullscreen mode

🌍 5. EnvironmentObject Over-Retention

EnvironmentObjects are global strong references.

.environmentObject(AppState())
Enter fullscreen mode Exit fullscreen mode

If AppState holds:

  • services
  • caches
  • closures
  • observers

Everything downstream stays alive.

Rule
📌 EnvironmentObjects should be thin coordinators, not heavy services.


🧠 6. AppState vs Feature State

Bad:

class AppState {
    let featureA = FeatureAViewModel()
    let featureB = FeatureBViewModel()
}
Enter fullscreen mode Exit fullscreen mode

Nothing can deallocate.

Better:

  • AppState holds identifiers & flags
  • Features own their own ViewModels
  • Navigation creates & destroys feature state

🔁 7. Combine & Notification Leaks

Any long-lived subscription can leak:

publisher
    .sink { value in
        self.handle(value)
    }
    .store(in: &cancellables)
Enter fullscreen mode Exit fullscreen mode

If self never deallocates, neither does the pipeline.

Rule

  • Cancel on disappear
  • Scope subscriptions tightly
  • Prefer short-lived pipelines

🧪 8. Detecting Leaks Early

Add this to every ViewModel:

deinit {
    print("Deinit:", Self.self)
}
Enter fullscreen mode Exit fullscreen mode

If you don’t see it:

  • something owns it
  • navigation path
  • task
  • environment
  • closure

🧭 9. Memory in Multi-Window Apps

Each window:

  • owns its own view tree
  • owns its own navigation state
  • may duplicate ViewModels

Never share ViewModels across windows unless intentional.

Memory leaks multiply quickly here.


❌ 10. Common SwiftUI Memory Anti-Patterns

Avoid:

  • global singletons for ViewModels
  • storing views in objects
  • long-running tasks without cancellation
  • heavy EnvironmentObjects
  • closures capturing self
  • mixing navigation & state ownership

🧠 Mental Checklist

Ask yourself:

  • Who owns this object?
  • When should it deallocate?
  • What captures it?
  • What retains it?
  • Does navigation keep it alive?
  • Does a task keep it alive?

If you can’t answer these, you’ll leak.


🚀 Final Thoughts

SwiftUI doesn’t magically manage memory for you.

It gives you:

  • predictable lifecycles
  • clear ownership boundaries
  • powerful abstractions

But you must still design ownership correctly.

When memory is clean:

  • performance stabilizes
  • bugs disappear
  • navigation behaves
  • long sessions stay smooth

Top comments (0)