Concurrency Deep Dive

Managing Multiple Subscriptions

March 2, 2026
5 min read
Featured image for blog post: Managing Multiple Subscriptions

The Missing Cancellable Problem showed why you must store cancellables. But what happens when you need to track multiple subscriptions?

Follow along with the code: iOS-Practice on GitHub

The Single Cancellable Trap

A common pattern that breaks:

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 fetchMultipleEndpoints(_ urls: [URL]) {
        for url in urls {
            fetchData(from: url)  // Each call overwrites cancellable!
        }
    }
}

Call fetchMultipleEndpoints with 3 URLs:

  1. First request starts, stored in cancellable
  2. Second request starts, overwrites cancellable → first request cancelled
  3. Third request starts, overwrites cancellable → second request cancelled

Only the last request completes. The others are silently cancelled.

Solution 1: Set<AnyCancellable>

The most common pattern—store all subscriptions in 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)
    }

    func fetchMultipleEndpoints(_ urls: [URL]) {
        for url in urls {
            fetchData(from: url)  // All requests tracked
        }
    }

    func cancelAll() {
        cancellables.removeAll()  // Cancels everything
    }
}

Every subscription goes into the Set. They all live until completion or until you clear the Set.

Why Set instead of Array? Both work identically here—each .sink() creates a new AnyCancellable, so duplicates don't naturally occur. The Set is convention: it signals "unordered bag of subscriptions I need to keep alive" and has O(1) removal if you ever need it. An array works fine too; .store(in:) supports both.

Solution 2: Dictionary for Identified Subscriptions

When you need to cancel specific subscriptions:

class ImageLoader: ObservableObject {
    @Published var images: [String: Data] = [:]
    private var loadTasks: [String: AnyCancellable] = [:]

    func loadImage(named name: String) {
        // Cancel any existing load for this image
        loadTasks[name]?.cancel()

        loadTasks[name] = URLSession.shared.dataTaskPublisher(for: imageURL(name))
            .sink(
                receiveCompletion: { [weak self] _ in
                    self?.loadTasks.removeValue(forKey: name)
                },
                receiveValue: { [weak self] response in
                    self?.images[name] = response.data
                }
            )
    }

    func cancelLoad(named name: String) {
        loadTasks[name]?.cancel()
        loadTasks.removeValue(forKey: name)
    }

    func cancelAll() {
        loadTasks.values.forEach { $0.cancel() }
        loadTasks.removeAll()
    }
}

Each subscription is keyed by an identifier. You can cancel specific loads without affecting others.

Solution 3: UUID Tracking

When subscriptions don't have a natural identifier:

class RequestManager: ObservableObject {
    private var activeRequests: [UUID: AnyCancellable] = [:]

    @discardableResult
    func makeRequest(to url: URL, completion: @escaping (Data?) -> Void) -> UUID {
        let requestId = UUID()

        let cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .sink(
                receiveCompletion: { [weak self] _ in
                    self?.activeRequests.removeValue(forKey: requestId)
                },
                receiveValue: { response in
                    completion(response.data)
                }
            )

        activeRequests[requestId] = cancellable
        return requestId
    }

    func cancelRequest(_ id: UUID) {
        activeRequests[id]?.cancel()
        activeRequests.removeValue(forKey: id)
    }

    func cancelAll() {
        activeRequests.values.forEach { $0.cancel() }
        activeRequests.removeAll()
    }
}

The caller gets a UUID they can use to cancel the specific request later.

Cleanup on Completion

Don't forget to remove completed subscriptions from dictionaries:

loadTasks[name] = publisher
    .sink(
        receiveCompletion: { [weak self] _ in
            // Clean up when done
            self?.loadTasks.removeValue(forKey: name)
        },
        receiveValue: { ... }
    )

Without this, your dictionary grows indefinitely with stale entries.

The NotificationCenter Problem

NotificationCenter subscriptions don't complete—they run until cancelled:

class NotificationListener: ObservableObject {
    private var cancellables = Set<AnyCancellable>()

    func startListening() {
        NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
            .sink { [weak self] _ in
                self?.handleBecameActive()
            }
            .store(in: &cancellables)

        NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
            .sink { [weak self] _ in
                self?.handleResignActive()
            }
            .store(in: &cancellables)
    }

    func stopListening() {
        cancellables.removeAll()  // Actually stops them
    }
}

Which Pattern to Use

ScenarioPattern
Fire-and-forget subscriptionsSet<AnyCancellable>
Cancel specific by ID[ID: AnyCancellable]
Replace previous (e.g., search)Single AnyCancellable?
Callers need cancel handleReturn UUID

The Rule

One cancellable per subscription, organized for your cancel needs. If you overwrite a cancellable, you cancel the previous subscription. Use a Set when you just need them to live, use a Dictionary when you need targeted cancellation.

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