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()
}
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()
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()
}
}
}
This is a retain cycle.
Fix
onUpdate = { [weak self] in
self?.doSomething()
}
SwiftUI does not protect you from this.
⏳ 4. Async Tasks That Never Die
This is extremely common:
.task {
await loadData()
}
If loadData():
- loops
- awaits long-lived tasks
- never completes
The task retains the view model.
✅ Safe pattern
.task {
await loadData()
}
.onDisappear {
cancelTasks()
}
Or:
Task { [weak self] in
await self?.loadData()
}
🌍 5. EnvironmentObject Over-Retention
EnvironmentObjects are global strong references.
.environmentObject(AppState())
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()
}
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)
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)
}
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)