Home

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:

  1. The cancellable was released. The owning object was deallocated, or .store(in:) was forgotten. Most common by a wide margin.
  2. An error terminated the stream. Something upstream failed, a catch was in the wrong place, and the subscription completed. Covered in the failure chapter.
  3. A subject was completed. Somebody sent .finished, and every later send is ignored.
  4. 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.