Concurrency Deep Dive

Combine Retain Cycles: [weak self] Matters

February 21, 2026
5 min read
Featured image for blog post: Combine Retain Cycles: [weak self] Matters

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 → cancellables
  • cancellables → 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:

  1. Present the view/object
  2. Interact with it (trigger subscriptions)
  3. Dismiss/release it
  4. 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch