Concurrency Deep Dive

The Missing Cancellable Problem

February 27, 2026
5 min read
Featured image for blog post: 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:

  1. A place to store cancellables
  2. Weak self in the closure (usually)
  3. .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.

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