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:
- First request starts, stored in
cancellable - Second request starts, overwrites
cancellable→ first request cancelled - 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
| Scenario | Pattern |
|---|---|
| Fire-and-forget subscriptions | Set<AnyCancellable> |
| Cancel specific by ID | [ID: AnyCancellable] |
| Replace previous (e.g., search) | Single AnyCancellable? |
| Callers need cancel handle | Return 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.