How do Combine subscriptions create cycles, and why is assign(to:on:) a trap?

3.3.5 · L3 · Interview

Predict first

What does this print with a strong sink?

import Combine

final class ViewModel {
    var bag = Set<AnyCancellable>()
    @Published var value = 0
    init() { $value.sink { _ in self.log() }.store(in: &bag) }
    func log() {}
    deinit { print("deinit") }
}

var vm: ViewModel? = ViewModel()
vm = nil
print("after")
  • deinit, after
  • after

Predict first

What does this print with [weak self]?

import Combine

final class ViewModel {
    var bag = Set<AnyCancellable>()
    @Published var value = 0
    init() { $value.sink { [weak self] _ in self?.log() }.store(in: &bag) }
    func log() {}
    deinit { print("deinit") }
}

var vm: ViewModel? = ViewModel()
vm = nil
print("after")
  • deinit, after
  • after

Predict first

What does this print for assign(to:on:)?

import Combine
import Foundation

final class Model {
    var x = 0
    var bag = Set<AnyCancellable>()
    init() {
        Just(1).delay(for: 60, scheduler: DispatchQueue.main)
            .assign(to: \.x, on: self).store(in: &bag)
    }
    deinit { print("deinit") }
}

var m: Model? = Model()
m = nil
print("after")
  • deinit, after
  • after

Mental model. You subscribe to a magazine and keep the subscription card in your own wallet. The publisher mails to you, and you carry the card that keeps the mail coming.

A common pattern stores subscriptions in the object they report to: publisher.sink { self.update($0) }.store(in: &cancellables). The chain is self to cancellables to subscription to closure to self: a cycle, so the view model never deinitialises. Fix it with [weak self] in the sink closure. Storing the AnyCancellable is still right: it cancels when the owner dies, which only happens once the cycle is broken.

assign(to:on:) hides the same problem: it retains the object you pass strongly for the life of the subscription, so assign(to: \.name, on: self) stored in self is a cycle with no closure in sight. Use a weak sink, or assign into a @Published property with assign(to: &$property), which does not retain the object.

The view model holds the subscription, and the subscription’s closure holds the view model.

The weak capture removes the loop, so the view model frees and its stored subscription is cancelled with it.

assign(to:on:) retains self strongly for as long as the subscription lives, which is until it completes or is cancelled.

Common traps

  • .assign(to:on: self) stored in self. It retains self strongly; there is no capture list to fix.
  • Storing the cancellable and calling it safe. It cancels only if the owner can deinit, which the strong capture prevents.

They might follow up with

  • Does the cancellable need to be stored at all? Yes: when it deinitialises the subscription is cancelled, so dropping it early stops the stream.
  • What is the async equivalent? A Task over an AsyncSequence has the same strong-capture risk; see the next question.

Related questions

  • 3.3.3 When does a closure create a cycle, and why doesn’t a lazy var closure always?
  • 3.3.6 Can a Task cause a retain cycle, and how do you avoid it?

← Why does a Timer keep your object alive, and how do you stop it?   Can a Task cause a retain cycle, and how do you avoid it? →