How forgetting [weak self] can silently kill your app’s performance
Introduction
Picture this: You’ve just shipped your beautifully crafted iOS app. It uses Combine for reactive programming, follows MVVM architecture, and the code looks clean. But users are complaining about crashes and sluggish performance. After hours of debugging, you discover the culprit: memory leaks caused by retain cycles in your Combine subscriptions.
Today, we’ll dive deep into one of the most common yet overlooked issues in Swift development: memory leaks in Combine’s sink operator.
The Innocent-Looking Code That Leaks
Let’s start with a real-world example that looks perfectly reasonable:
class ProfileViewController: UIViewController {
private var viewModel = ProfileViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
// This looks fine, right? π€
viewModel.$userName
.sink { userName in
self.updateProfileName(userName)
}
.store(in: &cancellables)
}
private func updateProfileName(_ name: String) {
// Update UI
}
}
Spoiler alert: This code has a memory leak that will prevent ProfileViewController from ever being deallocated!
Understanding the Retain Cycle
To understand why this leaks, let’s visualize what’s happening:
βββββββββββββββββββββββ
β ProfileViewController ββββββββ (strong reference)
β β β
β β’ cancellables β βΌ
βββββββββββββββββββββββ βββββββββββββββ
β² β Cancellables β
β β Set β
β βββββββββββββββ
β (strong) β
β β (strong)
β βΌ
β βββββββββββββββ
β β Subscriptionβ
β βββββββββββββββ
β β
β β (strong)
β βΌ
β βββββββββββββββ
βββββββββββββββββ Closure β
(captures self) β { self...} β
βββββββββββββββ
Here’s the retain cycle:
ProfileViewControllerownscancellablescancellablesowns the subscription- The subscription owns the sink closure
- The closure captures
selfstrongly
Result: A perfect circle of strong references! π
The Memory Leak in Action
Let’s prove this is actually happening:
class LeakyViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
init() {
super.init(nibName: nil, bundle: nil)
print("β
LeakyViewController created")
}
deinit {
print("β»οΈ LeakyViewController destroyed")
// β οΈ This will NEVER print!
}
override func viewDidLoad() {
super.viewDidLoad()
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { _ in
print("Timer fired in \(self)")
}
.store(in: &cancellables)
}
}
When you dismiss this view controller, you’ll notice:
- The “destroyed” message never prints
- The timer keeps firing forever
- Memory usage keeps growing
The Simple Fix: [weak self]
The solution is elegantly simple:
viewModel.$userName
.sink { [weak self] userName in
self?.updateProfileName(userName)
}
.store(in: &cancellables)
Or with guard for cleaner code:
viewModel.$userName
.sink { [weak self] userName in
guard let self = self else { return }
self.updateProfileName(userName)
}
.store(in: &cancellables)
Why This Works
With [weak self], the closure holds only a weak reference to the view controller:
ProfileViewController ββstrongββ> cancellables
β
strong
βΌ
Subscription
β
strong
βΌ
Closure
β
weak
βΌ
ProfileViewController
No cycle! The view controller can now be deallocated normally.
Modern Swift Alternative: AsyncSequence
Swift’s new concurrency features offer an even cleaner solution:
override func viewDidLoad() {
super.viewDidLoad()
Task { [weak self] in
for await userName in viewModel.$userName.values {
guard let self else { break }
self.updateProfileName(userName)
}
}
}
Benefits:
- No
cancellablesto manage - Automatic cancellation on deallocation
- More readable, linear code flow
Best Practices to Prevent Memory Leaks
1. Always Use [weak self] in Combine Sinks
Make it a habit, even if you think the subscription is short-lived.
// β
Good
publisher.sink { [weak self] value in
self?.handle(value)
}
// β Bad
publisher.sink { value in
self.handle(value)
}
2. Use SwiftLint Rules
Add this to your .swiftlint.yml:
custom_rules:
combine_weak_self:
name: "Combine Weak Self"
regex: '\.sink\s*\{(?!\s*\[weak)'
message: "Use [weak self] in sink closures"
severity: warning
3. Create a Safe Sink Extension
extension Publisher where Failure == Never {
func weakSink<T: AnyObject>(
to object: T,
_ handler: @escaping (T, Output) -> Void
) -> AnyCancellable {
sink { [weak object] value in
guard let object = object else { return }
handler(object, value)
}
}
}
// Usage
viewModel.$userName
.weakSink(to: self) { vc, userName in
vc.updateProfileName(userName)
}
.store(in: &cancellables)
4. Test for Leaks
class MemoryLeakTests: XCTestCase {
func testViewControllerDeallocates() {
var vc: ProfileViewController? = ProfileViewController()
weak var weakVC = vc
vc?.loadViewIfNeeded()
vc = nil
XCTAssertNil(weakVC, "View controller leaked!")
}
}
Real-World Impact
Memory leaks might seem minor, but consider:
- Navigation Stack: Each pushed view controller that leaks stays in memory
- Data Accumulation: Leaked objects continue receiving and processing data
- Battery Drain: Timers and observers in leaked objects keep running
- Crash Risk: Eventually, your app runs out of memory
A single [weak self] can be the difference between a 5-star app and one that crashes constantly.
Debugging Tips
Use Memory Graph Debugger
- Run your app
- Navigate through several screens
- Click the Memory Graph button in Xcode
- Look for unexpected instances of your view controllers
Add Allocation Tracking
class BaseViewController: UIViewController {
static var instanceCount = 0
override init(nibName: String?, bundle: Bundle?) {
super.init(nibName: nibName, bundle: bundle)
Self.instanceCount += 1
print("π \(Self.self) instances: \(Self.instanceCount)")
}
deinit {
Self.instanceCount -= 1
print("π \(Self.self) instances: \(Self.instanceCount)")
}
}
Conclusion
Memory leaks from retain cycles are one of the most common bugs in iOS development, yet they’re completely preventable. The next time you write a Combine subscription, remember:
- Always use
[weak self]in sink closures - Consider modern alternatives like AsyncSequence
- Test your view controller deallocation
- Use tools to catch leaks early
Your users (and your future self) will thank you for writing leak-free code!
Resources
- Apple’s ARC Documentation
- WWDC 2021: Use async/await with URLSession
- Combine Framework Documentation
Did you find this helpful? Share it with your iOS developer friends who might be creating memory leaks without knowing it!
Leave a Reply