Home

Schedulers and threads

Where work happens, where values arrive, and why they are different questions

A Scheduler in Combine answers two questions: when something runs, and where. The protocol is small, and understanding it removes most Combine threading confusion.

protocol Scheduler {
    associatedtype SchedulerTimeType: Strideable
    var now: SchedulerTimeType { get }
    var minimumTolerance: SchedulerTimeType.Stride { get }

    func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void)
    func schedule(after date: SchedulerTimeType, tolerance: …, _ action: @escaping () -> Void)
}

Note now. A scheduler is not only an executor — it is also a clock, and that is what makes Combine’s time-based operators testable. debounce does not read the system time; it asks its scheduler. Swap the scheduler for one whose clock you control, and a three-second debounce test runs instantly. That is the whole trick of the testing chapter.

The publisher runs where it was subscribed

By default, an operator’s work happens on whatever thread delivered the value to it. There is no implicit thread hop anywhere in Combine — a map closure runs on the thread the upstream published from, and that thread might be a URLSession delegate queue, a Timer’s run loop, or whatever called send(_:) on a subject.

This has two consequences worth internalising:

A pipeline with no scheduler operators delivers on an essentially arbitrary thread. For URLSession.dataTaskPublisher, that is a background queue, which is why updating UI directly from its sink produces the main-thread-checker purple warnings.

Where you put receive(on:) decides what runs where. Everything below it moves; everything above it does not.

api.fetchItems()                                  // URLSession's queue
    .map { $0.filter(\.isVisible) }               // still URLSession's queue — good
    .receive(on: DispatchQueue.main)              // hop
    .sink { self.items = $0 }                     // main — good

Move that receive(on:) to the top and the filtering happens on the main queue for no reason.

Thread confinement in practice

Combine gives you no thread safety for free. A publisher may deliver on any thread, and a subject may be sent to from any thread, and neither is synchronised.

Three rules that keep it manageable:

  1. One receive(on: DispatchQueue.main) immediately before the sink, in any pipeline that ends in UI. Not earlier.
  2. Subjects are fed from one queue only. If more than one place calls send(_:), put a receive(on:) between the subject and everything downstream and be disciplined about the sending side, or move the state into an actor.
  3. @Published must be mutated on the main thread when a SwiftUI view observes it. SwiftUI does not do this hop for you and the failure is a crash in view update rather than an obvious threading error.

Warning

assign(to: \.property, on: object) performs no hop. Assigning from a background publisher straight to a UI property is one of the easiest ways to get a main-thread violation, and it looks completely innocent. Put receive(on: DispatchQueue.main) before every assign that touches UI.

Choosing a scheduler

Scheduler Use for
DispatchQueue.main UI delivery. The default choice at the end of a chain
DispatchQueue.global(qos:) Parsing, decoding, disk work
A private serial DispatchQueue Serialising access to one piece of state
RunLoop.main Legacy interop. Beware scroll tracking
OperationQueue When you need maxConcurrentOperationCount
ImmediateScheduler.shared Tests, where no delay is wanted at all

A private serial queue is under-used and often the right answer. If a subject is fed from several places, receive(on: myQueue) immediately after it makes every downstream operator run on one thread, which turns a class full of locks into one with none.

private let queue = DispatchQueue(label: "com.example.sync")

var events: AnyPublisher<Event, Never> {
    subject.receive(on: queue).eraseToAnyPublisher()
}

The qos argument matters more than it looks

DispatchQueue.global() defaults to .default, which is neither the fastest nor the most power-efficient choice. Being specific is cheap:

  • .userInitiated — the user is waiting and watching. Decoding the screen they just opened.
  • .utility — progress is visible but the user is doing something else. A sync, a download.
  • .background — nobody is waiting. Prefetching, cleanup, analytics upload.

Getting this wrong in the wasteful direction — everything at .userInitiated — causes thread explosion and battery drain, and on a device with efficiency cores it actively prevents the scheduler from doing its job.

Timers, and why Timer.publish needs connect

Timer.publish returns a ConnectablePublisher, which does nothing until told to start:

let timer = Timer.publish(every: 1, on: .main, in: .common)
    .autoconnect()                                     // start on first subscription
    .sink { print($0) }

Without .autoconnect() — or an explicit .connect() — the timer never fires, and this is a common “my timer does not work” report. Note also in: .common rather than .default: the default run loop mode pauses during scroll tracking, which is the same trap as RunLoop.main in the previous chapter, in a different costume.