Cancellation and lifetime
AnyCancellable, the cycles it invites, and streams that stop silently
A Combine subscription has no lifetime of its own. It lives exactly as long as something holds the
AnyCancellable that sink or assign returned, and it dies the moment that reference goes.
func brokenFetch() {
api.fetchItems()
.sink { print($0) } // AnyCancellable discarded at the end of this line
}
That prints nothing, ever. The cancellable is deallocated immediately, cancel() runs, and the
request is torn down before it completes. No warning, no error — Swift will emit an unused-result
warning for sink, which is the only clue you get.
The standard fix is a stored set:
final class ItemsModel {
private var cancellables: Set<AnyCancellable> = []
func load() {
api.fetchItems()
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.items = $0 }
.store(in: &cancellables)
}
}
What cancellation actually does
cancel() propagates upstream. Every operator tears down in turn, and the source publisher gets
to clean up — a URLSession request is genuinely cancelled, a timer is invalidated, a file handle
is closed.
It does not deliver a completion. A cancelled stream’s sink receives neither .finished nor
.failure; it simply stops being called. This is deliberate, and it is why cancellation cannot be
observed from the receive-completion closure — use handleEvents(receiveCancel:) if you need to
know.
publisher
.handleEvents(
receiveSubscription: { _ in print("subscribed") },
receiveOutput: { print("value: \($0)") },
receiveCompletion: { print("completed: \($0)") },
receiveCancel: { print("cancelled") }
)
.sink { … }
handleEvents is the debugger of this framework. When a pipeline does nothing and you cannot see
why, inserting it at two points tells you whether values are arriving, whether the stream completed,
or whether it was cancelled out from under you.
The retain cycles
Two shapes cause almost all of them.
self captured in a sink stored on self:
// cycle: self → cancellables → subscription → closure → self
.sink { self.items = $0 }
// fixed
.sink { [weak self] in self?.items = $0 }
assign(to:on:) targeting self:
// cycle: assign holds `on:` strongly
.assign(to: \.items, on: self)
// fixed — this overload manages lifetime and does not retain
.assign(to: &$items)
The assign(to: &$published) form is genuinely different from assign(to:on:), not a stylistic
variant. It requires a @Published property and takes it inout, and it does not create a
cancellable at all — the lifetime is tied to the published property.
Warning
[weak self] on the closure is not enough if the pipeline also has an assign(to:on: self)
further along, or captures self inside a map. The cycle is created by any strong capture
anywhere in the chain, not only in the final closure.
Streams that stop silently
Combine’s worst failure mode is silence, and there are exactly four causes. When a pipeline stops working, check them in this order:
- The cancellable was released. The owning object was deallocated, or
.store(in:)was forgotten. Most common by a wide margin. - An error terminated the stream. Something upstream failed, a
catchwas in the wrong place, and the subscription completed. Covered in the failure chapter. - A subject was completed. Somebody sent
.finished, and every latersendis ignored. - Demand ran out. Only with a custom subscriber, and rare — but it looks exactly like the others.
handleEvents(receiveCompletion:receiveCancel:) distinguishes 2 and 3 from 1 and 4 in about thirty
seconds, which is why it is worth reaching for before reading the code again.
Scoping subscriptions to a lifetime
Storing everything in one Set<AnyCancellable> that lives as long as the object is the common
pattern, and it is coarse. Two refinements are worth knowing.
Cancel a group by replacing the set. Assigning a fresh Set releases every cancellable in the
old one, tearing down that whole group of subscriptions at once:
func reset() {
cancellables = [] // every subscription cancelled
}
Keep a single cancellable for a replaceable subscription. When a new subscription should replace an old one — a detail screen following a selection — hold one optional property rather than a set:
private var currentRequest: AnyCancellable?
func select(_ id: Item.ID) {
currentRequest = api.fetchItem(id) // assignment cancels the previous one
.sink { … }
}
The assignment releases the previous AnyCancellable, which cancels the in-flight request. That is
switchToLatest semantics achieved through ownership rather than an operator, and it reads better
when the trigger is a method call rather than a stream.