The Hidden Memory Leak in Your Combine Code: A Swift Developer’s Guide

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:

  1. ProfileViewController owns cancellables
  2. cancellables owns the subscription
  3. The subscription owns the sink closure
  4. The closure captures self strongly

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 cancellables to 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

  1. Run your app
  2. Navigate through several screens
  3. Click the Memory Graph button in Xcode
  4. 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:

  1. Always use [weak self] in sink closures
  2. Consider modern alternatives like AsyncSequence
  3. Test your view controller deallocation
  4. Use tools to catch leaks early

Your users (and your future self) will thank you for writing leak-free code!

Resources

Did you find this helpful? Share it with your iOS developer friends who might be creating memory leaks without knowing it!

In

,

Leave a Reply

Your email address will not be published. Required fields are marked *