What is the difference between receive(on: ImmediateScheduler) and receive(on: DispatchQueue.main) for the order of code?

11.9.2 · L3 · Interview

Predict first

In what order do the lines print?

import Combine
import Foundation

var bag = Set<AnyCancellable>()
print("A start")
[1, 2].publisher.receive(on: ImmediateScheduler.shared).sink { print("immediate", $0) }.store(in: &bag)
print("B after immediate")
[3, 4].publisher.receive(on: DispatchQueue.main).sink { print("main queue", $0) }.store(in: &bag)
print("C after main queue")
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
print("D end")
  • A start / immediate 1 / immediate 2 / B after immediate / C after main queue / main queue 3 / main queue 4 / D end
  • A start / main queue 3 / main queue 4 / immediate 1 / immediate 2 / B / C / D
  • A start / B after immediate / immediate 1 / immediate 2 / C after main queue / main queue 3 / main queue 4 / D end

Mental model. Immediate is answering a question the moment it is asked. The main queue is taking a number and being called after everyone ahead has been served, even if the queue is empty.

ImmediateScheduler.shared runs the work synchronously, right now, on the current thread: the two values arrive before the next line runs (“immediate 1, immediate 2, B after immediate”). DispatchQueue.main as a scheduler always enqueues, even if you are already on the main thread, so the values arrive after the current code finishes and the run loop turns: the log prints “C after main queue” before “main queue 3” and “main queue 4”. That is the same rule as DispatchQueue.main.async never running inline (see the nested main-queue puzzle).

Why this matters: code that assumes “a sink fires by the time the subscribe line returns” works with synchronous sources and the immediate scheduler, but breaks as soon as a receive(on: DispatchQueue.main) is added, because delivery moves to a later turn. This is why tests that check results immediately after subscribing are flaky with queue-backed schedulers: either wait (an expectation, .values), or inject a scheduler you control (ImmediateScheduler in tests, a test scheduler type of your own, or the open-source CombineSchedulers package) through AnyScheduler or a generic parameter. Keep the scheduler an injected dependency of the pipeline so production uses the main queue and tests use the immediate one (Q313).

Immediate delivers synchronously; the main queue delivers after the current code yields.

Common traps

  • Testing a pipeline that uses DispatchQueue.main by asserting right after subscribe. The values have not arrived yet.
  • Using ImmediateScheduler in production to “avoid a hop”. It does not move you to the right thread.

They might follow up with

  • How do I make debounce testable? Inject the scheduler and drive virtual time with a test scheduler rather than sleeping.
  • Does RunLoop.main behave like the main queue? Similar, but run-loop modes apply (Q217), and the order relative to queue-delivered blocks is an implementation detail.

Related questions

  • 11.9.1 Which thread does each stage of a pipeline run on, and what do subscribe(on:) and receive(on:) each move?
  • 11.9.3 What do debounce, throttle(latest: true) and throttle(latest: false) each emit for a burst of events, and…
  • 11.6.2 How do you test Combine pipelines, and what is a TestScheduler pattern?
  • 6.3.2 What are run loop modes, and why does a Timer stop firing while the user scrolls?
  • 27.3.1 Blocks are scheduled on the main queue from the main thread, and one of them schedules another. In what order…

← Which thread does each stage of a pipeline run on, and what do…   What do debounce, throttle(latest: true) and throttle(latest: false)… →