Combine
Irbisa · cheatsheetSeptember 13, 2026

Combine

Publishers, Subscribers, operators, Subjects, bridging with async/await

Senior Developer20 itemscompressed for a skim
  1. 01

    What is Combine? How do Publishers, Subscribers, and Operators relate?

    Medium

    Combine models asynchronous work as a typed pipeline built from four roles.

    import Combine
    
    let publisher = [1,2,3,4,5].publisher
      .map { $0 * 2 }
      .filter { $0 > 4 }
      .eraseToAnyPublisher()
    …
  2. 02

    What are Subjects in Combine? PassthroughSubject vs CurrentValueSubject.

    Medium

    Subjects are publishers you can imperatively send values into — the bridge between non-Combine code and a Combine pipeline.

    import Combine
    
    // Event subject — fire-and-forget events
    let tapSubject = PassthroughSubject<Void, Never>()
    let bag = tapSubject.sink { print("tapped") }
    tapSubject.send()
    …
  3. 03

    Which Combine operators cover transformation, combining streams, rate limiting and scheduling?

    Medium

    Combine operators group into five jobs: transform, filter, combine, rate-limit and schedule.

    import Combine
    
    // Typeahead pipeline
    class SearchVM: ObservableObject {
      @Published var query = ""
      @Published var results: [Result] = []
    …
  4. 04

    How does SwiftUI observe an ObservableObject, and what has replaced that pattern?

    Medium

    SwiftUI observes a Combine-based view model through ObservableObject and @Published.

    import SwiftUI
    import Combine
    
    final class CartVM: ObservableObject {
      @Published private(set) var items: [Item] = []
      @Published var coupon: String = ""
    …
  5. 05

    How do Combine and Swift Concurrency (async/await) interoperate?

    Hard

    Combine and async/await interoperate in both directions, and Apple's direction of travel points from Combine toward async/await.

    import Combine
    
    // Publisher → async
    let publisher = url.dataTaskPublisher.map(\.data)
    for try await data in publisher.values {
      print(data.count)
    …
  6. 06

    Which Combine mistakes silently break a pipeline, and how do you avoid them?

    Hard

    Most Combine bugs come from a pipeline that was never retained, retained too strongly, or delivered on the wrong scheduler.

    // Forgot to store — pipeline silently dies
    func bad() {
      somePublisher.sink { _ in print("never") }       // ⚠️ AnyCancellable discarded
    }
    
    // Fix
    …
  7. 07

    A search field fires a request per keystroke and stale results overwrite fresh ones. Which operator fixes it?

    Medium

    Switching to the latest inner publisher is the fix, because flatMap keeps every request alive and merges their results, so a slow early response can land after a fast late one.

    final class SearchViewModel: ObservableObject {
      @Published var query = ""
      @Published private(set) var results: [Match] = []
      private var bag = Set<AnyCancellable>()
    
      init(api: SearchAPI) {
    …
  8. 08

    One expensive publisher, three subscribers. How many requests fire, and how do you share the work?

    Hard

    Three, because every subscription runs the whole chain again unless you deliberately multicast the publisher.

    let request = URLSession.shared
      .dataTaskPublisher(for: url)
      .map(\.data)
      .handleEvents(receiveSubscription: { _ in print("network hit") })
    
    // Three subscribers, three requests
    …
  9. 09

    How do you test a debounced Combine pipeline without sleeping in the test?

    Hard

    Inject the scheduler instead of hard-coding a queue, so the test can advance time by hand rather than waiting for it.

    final class SearchViewModel {
      @Published var query = ""
      private(set) var results: [Match] = []
      private var bag = Set<AnyCancellable>()
    
      init(api: SearchAPI, scheduler: AnySchedulerOf<DispatchQueue>) {
    …
  10. 10

    Swift 6 language mode lights up a Combine-heavy module with errors. What is failing and how do you fix it?

    Hard

    Combine predates Sendable and has no isolation model, so almost every error is a closure carrying values across an isolation boundary the compiler will no longer take on trust.

    // Before: mutating main-actor state inside a sink
    final class FeedViewModel {
      @MainActor private(set) var posts: [Post] = []
      private var bag = Set<AnyCancellable>()
    
      func start(_ upstream: AnyPublisher<[Post], Never>) {
    …
  11. 11

    In a UIKit screen, which Combine publishers does the SDK actually hand you?

    Medium

    Apple vends a short list: notifications, KVO, timers, URLSession tasks and your own @Published properties — everything else you write yourself.

    final class ComposeViewController: UIViewController {
      private var bag = Set<AnyCancellable>()
      private let sendButton = UIButton(type: .system)
      private let textField = UITextField()
    
      override func viewDidLoad() {
    …
  12. 12

    Why won't these two publishers combine, and how do you keep a stream alive after an error?

    Medium

    Failure is part of a publisher's type, so combineLatest and zip refuse to compile until both sides agree on it, and any failure ends the subscription permanently.

    // Does not compile: Failure types disagree
    // let merged = Publishers.CombineLatest(profilePublisher, settingsPublisher)
    
    // Fix 1 — map both to a shared error type
    let merged = profilePublisher
      .mapError { AppError.network($0) }
    …
  13. 13

    You wrap a network call in a Future and the request fires before anyone subscribes, then never runs again — why?

    Medium

    Future runs its closure once, at initialization, and caches that single result for every subscriber — it is a promise, not a recipe.

    // Eager: the closure runs at init, not at subscribe
    func badFetch(id: String) -> AnyPublisher<User, Error> {
      Future { promise in
        print("request fired")              // prints before anyone subscribes
        Task {
          do { promise(.success(try await api.user(id))) }
    …
  14. 14

    JSON decoding still blocks the main thread even though the pipeline ends with receive(on: .main) — what did you get wrong?

    Medium

    receive(on:) only moves the operators written below it, so a decode placed after the hop runs on the main queue — the hop must be the last step, not an early one.

    // Wrong: the hop to main happens before the expensive work
    URLSession.shared.dataTaskPublisher(for: url)
      .receive(on: DispatchQueue.main)
      .map(\.data)
      .decode(type: [Post].self, decoder: JSONDecoder())    // decodes ON the main thread
      .replaceError(with: [])
    …
  15. 15

    Inside a sink on $items you read the view model's items and get the previous array — what is Combine doing?

    Medium

    @Published publishes from willSet, so your subscriber runs before the stored property is written: the closure argument is the new value while the property still holds the old one.

    final class CartViewModel: ObservableObject {
      @Published var items: [Item] = []
      @Published var coupon: String?
      @Published private(set) var total: Total = .zero
      private var bag = Set<AnyCancellable>()
    …
  16. 16

    A Timer.publish countdown freezes while the user drags a scroll view and jumps forward when they let go — why?

    Medium

    The timer was enrolled in RunLoop.Mode.default, and while a finger is dragging a scroll view the main run loop spins in the tracking mode, so nothing registered in the default mode gets a turn.

    // Freezes while a scroll view is being dragged: default mode only
    let stalling = Timer.publish(every: 1, on: .main, in: .default).autoconnect()
    
    // Keeps ticking during tracking
    private let deadline = Date().addingTimeInterval(300)
    private var bag = Set<AnyCancellable>()
    …
  17. 17

    A socket pushes thousands of messages a second into a sink, the UI falls behind and memory climbs — Combine is demand-driven, so why?

    Hard

    sink and assign request .unlimited demand, so from the moment you attach one the backpressure protocol is switched off and every value is pushed straight through.

    // sink asks for unlimited demand — every message becomes a queued main-thread block
    socket.messages
      .receive(on: DispatchQueue.main)
      .sink { [weak self] in self?.rows.append($0) }   // main never catches up, memory climbs
      .store(in: &bag)
    …
  18. 18

    A dashboard zips a fast price feed with a slow status feed, memory grows all day and the numbers lag — what is zip doing?

    Hard

    zip pairs by position, so it holds every unmatched element of the faster side in an unbounded buffer and the pairs it does emit get older and older.

    // Leak with a schedule: prices tick 50x a second, status once a minute
    prices
      .zip(status)                       // ~3000 prices buffered per status update
      .sink { price, status in render(price, status) }   // and the pair is a minute stale
      .store(in: &bag)
    …
  19. 19

    A sink writes back into the subject that fed it and the app recurses to death — why doesn't Combine stop the reentrancy?

    Hard

    Combine delivers synchronously on whatever thread called send, with no reentrancy guard at all: your sink runs inside that send, so writing back re-enters the same subject on the same stack.

    let events = PassthroughSubject<Event, Never>()
    
    // Reentrant: this sink runs INSIDE send, on the same stack
    events
      .sink { event in
        if event.needsFollowUp {
    …
  20. 20

    A pipeline emits twice in production and then goes quiet — how do you find where the events stop?

    Hard

    Tag each stage with print or handleEvents and read the lifecycle log: the first stage that stops printing values is where the chain died, and its last event says how.

    $query
      .print("1-query")
      .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
      .print("2-debounced")
      .map { api.search($0).print("3-request") }
      .switchToLatest()
    …