Combine Retain Cycles: [weak self] Matters
![Featured image for blog post: Combine Retain Cycles: [weak self] Matters](/blogposts/27/banner.jpg)
You dismiss a sheet. The object should deallocate. But deinit never prints. Your Combine subscription created a retain cycle.
Follow along with the code: iOS-Practice on GitHub
The Classic Mistake
class SearchService: ObservableObject {
@Published var searchText = ""
@Published var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { query in
self.performSearch(query: query) // đź’€ Strong self
}
.store(in: &cancellables)
}
deinit {
print("SearchService deallocated") // Never prints!
}
}
The closure captures self strongly. The subscription is stored in cancellables, which is owned by self. Now:
self→ owns →cancellablescancellables→ contains subscription → captures →self
Circular reference. Neither can be deallocated.
Why It Happens
Combine's sink captures variables in its closure just like any Swift closure. If you reference self without [weak self], it creates a strong reference.
The subscription lives in cancellables. As long as the subscription exists, it holds onto self. As long as self exists, it holds onto cancellables. Deadlock.
The Fix: [weak self]
init() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] query in
self?.performSearch(query: query)
}
.store(in: &cancellables)
}
Now the closure holds a weak reference to self. When the view dismisses and releases the SearchService, there's no strong reference keeping it alive. It deallocates, cancellables is released, and the subscription is cancelled.
The Sneaky [self] Capture
Watch out for this syntax that looks like it's handling self but isn't:
// This is NOT weak capture!
.sink { [self] date in
self.lastActivity = date
}
[self] just makes the capture explicit—it's still strong. You need [weak self]:
// This IS weak capture
.sink { [weak self] date in
self?.lastActivity = date
}
Multiple Subscribers
Each subscription needs [weak self]:
init() {
// ❌ BAD: Both create retain cycles
$isLoggedIn
.sink { isLoggedIn in
self.handleLoginChange(isLoggedIn)
}
.store(in: &cancellables)
$sessionToken
.sink { token in
self.validateToken(token)
}
.store(in: &cancellables)
// âś… GOOD: Both use weak self
$isLoggedIn
.sink { [weak self] isLoggedIn in
self?.handleLoginChange(isLoggedIn)
}
.store(in: &cancellables)
$sessionToken
.sink { [weak self] token in
self?.validateToken(token)
}
.store(in: &cancellables)
}
When Strong Self is Safe
Sometimes strong capture is intentional:
// One-shot publisher that completes
URLSession.shared.dataTaskPublisher(for: url)
.sink(
receiveCompletion: { _ in },
receiveValue: { self.data = $0.data } // OK if you want this
)
.store(in: &cancellables)
If the publisher completes quickly and you want self to stay alive until completion, strong capture is fine. But ask yourself: what if the network request takes 30 seconds and the user navigates away? Do you still want self alive?
Usually, the answer is no. Use [weak self].
Testing for Retain Cycles
Add deinit prints to catch leaks during development:
deinit {
print("\(type(of: self)) deallocated")
}
Then test:
- Present the view/object
- Interact with it (trigger subscriptions)
- Dismiss/release it
- Check console for "deallocated" message
No message = retain cycle.
The Rule
Always use [weak self] in Combine sink closures unless you have a specific reason not to. The moment you type .sink {, your fingers should automatically add [weak self] _ in. It's the safe default.