What do receive(on:) and subscribe(on:) do, and how do you know which thread a stage runs on?

11.4.1 · L3 · Interview

Predict first

After receive(on:) a private queue, does the sink run on that queue?

import Combine
import Dispatch

var bag = Set<AnyCancellable>()
let q = DispatchQueue(label: "bg")
let key = DispatchSpecificKey<Bool>()
q.setSpecific(key: key, value: true)
let sem = DispatchSemaphore(value: 0)
nonisolated(unsafe) var onQ = false
Just(1).receive(on: q).sink { _ in
    onQ = DispatchQueue.getSpecific(key: key) == true
    sem.signal()
}.store(in: &bag)
sem.wait()
print(onQ)
  • true
  • false

Predict first

Does receive(on: DispatchQueue.main) deliver before or after the next line of code?

import Combine
import Foundation

var bag = Set<AnyCancellable>()
var order: [String] = []
Just("x").receive(on: DispatchQueue.main).sink { _ in order.append("sink") }.store(in: &bag)
order.append("after subscribe")
try? await Task.sleep(nanoseconds: 100_000_000)
print(order)
  • ["sink", "after subscribe"]
  • ["after subscribe", "sink"]

Predict first

With the immediate scheduler:

import Combine

var bag = Set<AnyCancellable>()
var imm: [Int] = []
Just(1).receive(on: ImmediateScheduler.shared).sink { imm.append($0) }.store(in: &bag)
print(imm)
  • []
  • [1]

Mental model. Each stage is a worker who passes a parcel on. Nothing changes the worker unless a hop (receive(on:)) hands the parcel to a different team.

A scheduler (DispatchQueue, OperationQueue, RunLoop, ImmediateScheduler) decides when and where a closure runs. Combine does nothing to threads by default: a value is processed on the thread of whoever sends it, all the way down the chain, so a URLSession publisher’s values arrive on a background queue and a subject’s on the caller’s thread. Two operators move work. receive(on:) hops downstream work to a scheduler: everything after it (operators and the final sink) runs there. It is the one you need for UI (.receive(on: DispatchQueue.main) just before sink or assign), and it can be used several times in one chain. subscribe(on:) moves the subscription (the work of starting the upstream and requesting values) to a scheduler, which matters only for publishers that do their work on subscription, such as a Deferred or a Future doing synchronous work; it does not move the delivery of values.

Details that matter. receive(on: DispatchQueue.main) is asynchronous even when already on the main thread: the value is enqueued, so code after the subscribe call runs before the sink (order: “after subscribe”, then “sink”), and tests must wait. Use ImmediateScheduler.shared to run synchronously (useful in tests, and a no-op hop in production). A hop costs a dispatch, so do it once near the end rather than between every operator, and do heavy transforms (decoding) on a background queue before hopping to the main one. Remember RunLoop.main and Timer publishers fire in the default run-loop mode, so they stall during scrolling (see the run-loop topic). To find the thread, use the debugger’s stack or log Thread.current in handleEvents; and know that SwiftUI’s @Published updates must still happen on the main thread.

Everything downstream of receive(on:) runs on the scheduler it names.

The hop is asynchronous, so the value is delivered on a later turn even though a plain Just would be synchronous.

The immediate scheduler runs the closure synchronously, which is why it is the scheduler of choice in tests.

Common traps

  • Updating UI in a sink without receive(on: DispatchQueue.main). The value arrives on a background queue.
  • Expecting receive(on: .main) to be synchronous on the main thread. It always defers by one turn.

They might follow up with

  • When does subscribe(on:) matter? When subscribing itself does heavy work; it does not move where values are delivered.
  • How do you make a pipeline synchronous in a test? Use ImmediateScheduler.shared for hops.

Related questions

  • 11.2.3 What do debounce, throttle and removeDuplicates do, and what do share and multicast change?
  • 11.6.2 How do you test Combine pipelines, and what is a TestScheduler pattern?
  • 6.3.1 What is a run loop, and what happens in one iteration?