The Missing Cancellable Problem

You subscribe to a publisher. Nothing happens. No events arrive. Your subscription vanished before it could receive anything.
Follow along with the code: iOS-Practice on GitHub
The Disappearing Subscription
class EventSubscriber: ObservableObject {
@Published var receivedEvents: [String] = []
func subscribeToEvents() {
EventBus.shared.events
.sink { [weak self] event in
self?.receivedEvents.append(event)
print("Received: \(event)") // Never prints!
}
// Subscription is immediately deallocated
}
}
The sink returns an AnyCancellable. If you don't store it, it's deallocated at the end of the statement. Deallocated = cancelled. Your subscription lived for a microsecond.
Why It Happens
AnyCancellable is designed to cancel its subscription when deallocated. This is usually helpful—it prevents leaks when objects go away. But if you don't store the cancellable, it goes away immediately:
// This subscription exists for one line
somePublisher.sink { value in
// Never called
}
// AnyCancellable deallocated here, subscription cancelled
The compiler won't warn you. The code compiles fine. It just doesn't work.
The Fix: Store Your Cancellables
class EventSubscriber: ObservableObject {
@Published var receivedEvents: [String] = []
private var cancellables = Set<AnyCancellable>()
func subscribeToEvents() {
EventBus.shared.events
.sink { [weak self] event in
self?.receivedEvents.append(event)
}
.store(in: &cancellables) // Now it lives
}
}
The subscription lives as long as cancellables exists, which is as long as EventSubscriber exists.
The Single Cancellable Trap
Another common mistake—overwriting a cancellable:
class DataFetcher: ObservableObject {
@Published var data: String = ""
private var cancellable: AnyCancellable? // Only one slot!
func fetchData(from url: URL) {
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] response in
self?.data = String(data: response.data, encoding: .utf8) ?? ""
}
)
}
func fetchMultiple(_ urls: [URL]) {
for url in urls {
fetchData(from: url) // Each call overwrites the previous!
}
}
}
With 3 URLs, you make 3 requests, but only the last one's cancellable is stored. The first two are cancelled immediately when overwritten.
The Fix: Use a Set
class DataFetcher: ObservableObject {
@Published var results: [URL: String] = [:]
private var cancellables = Set<AnyCancellable>()
func fetchData(from url: URL) {
URLSession.shared.dataTaskPublisher(for: url)
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] response in
let text = String(data: response.data, encoding: .utf8) ?? ""
self?.results[url] = text
}
)
.store(in: &cancellables) // All subscriptions stored
}
func fetchMultiple(_ urls: [URL]) {
for url in urls {
fetchData(from: url) // All requests tracked
}
}
func cancelAll() {
cancellables.removeAll()
}
}
Notification Center Subscriptions
This is especially common with NotificationCenter:
// ❌ BAD: Subscriptions immediately cancelled
func startListening() {
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { _ in print("Active") }
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
.sink { _ in print("Resigning") }
}
// ✅ GOOD: Subscriptions stored
private var cancellables = Set<AnyCancellable>()
func startListening() {
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { _ in print("Active") }
.store(in: &cancellables)
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
.sink { _ in print("Resigning") }
.store(in: &cancellables)
}
Xcode Warning (Sometimes)
Xcode sometimes warns about unused results:
Result of call to 'sink(receiveValue:)' is unused
But not always, especially with chained operators. Don't rely on the compiler to catch this.
The Pattern
Every Combine subscription needs:
class SomeClass {
private var cancellables = Set<AnyCancellable>() // 1. Storage
func subscribe() {
somePublisher
.sink { [weak self] value in // 2. Weak self
self?.handleValue(value)
}
.store(in: &cancellables) // 3. Store it
}
}
Three parts:
- A place to store cancellables
- Weak self in the closure (usually)
.store(in:)at the end
Miss any of these and you'll have bugs.
The Rule
Every .sink needs a .store(in:). If you're not storing the cancellable, you're not subscribed. Make it muscle memory: type .sink, immediately type .store(in: &cancellables). The subscription must live somewhere.