Schedulers and Threading in Combine

Schedulers control WHERE your Combine code runs. Get this wrong and you'll crash updating UI from background threads.
Follow along with the code: iOS-Practice on GitHub
Two Key Operators
.subscribe(on:) // Where work STARTS
.receive(on:) // Where values are DELIVERED
receive(on:) — Most Common
Move to main thread for UI updates:
api.fetchUsers()
.receive(on: DispatchQueue.main)
.sink { users in
self.users = users // Safe: on main thread
}
Everything AFTER receive(on:) runs on that scheduler.
subscribe(on:) — Less Common
Control where the publisher does its work:
heavyComputationPublisher
.subscribe(on: DispatchQueue.global(qos: .background))
.receive(on: DispatchQueue.main)
.sink { result in
self.result = result
}
The computation runs on background queue, but results arrive on main.
Order Matters
// Processing on background, delivery on main
publisher
.subscribe(on: DispatchQueue.global()) // Upstream work
.map { heavyTransform($0) } // Runs on global queue
.receive(on: DispatchQueue.main) // Switch to main
.sink { /* On main thread */ }
// vs
publisher
.receive(on: DispatchQueue.main) // Switch early
.map { heavyTransform($0) } // Runs on main (bad!)
.sink { /* On main thread */ }
Common Schedulers
// Main thread (UI updates)
DispatchQueue.main
// Background work
DispatchQueue.global(qos: .background)
DispatchQueue.global(qos: .userInitiated)
// Run loop (timers, delayed work)
RunLoop.main
// Immediate (testing, synchronous)
ImmediateScheduler.shared
RunLoop vs DispatchQueue.main
Both target the main thread, but behave differently:
// DispatchQueue.main - always async dispatch
.receive(on: DispatchQueue.main)
// RunLoop.main - integrates with run loop
.receive(on: RunLoop.main)
RunLoop.main is often better for UI because it integrates with the app's event loop. But DispatchQueue.main is more predictable.
Debounce and Throttle Need Schedulers
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { ... }
$sliderValue
.throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
.sink { ... }
Timer Publisher
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { date in
// Fires every second on main thread
}
Testing with ImmediateScheduler
Make async code synchronous for tests:
class ViewModel {
let scheduler: AnySchedulerOf<DispatchQueue>
init(scheduler: AnySchedulerOf<DispatchQueue> = .main) {
self.scheduler = scheduler
}
func setup() {
publisher
.receive(on: scheduler)
.sink { ... }
}
}
// In tests:
let vm = ViewModel(scheduler: .immediate)
// Now everything is synchronous
Complete Example
class DataViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
@Published var error: Error?
private var cancellables = Set<AnyCancellable>()
func load() {
isLoading = true
api.fetchItems()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.error = error
}
},
receiveValue: { [weak self] items in
self?.items = items
}
)
.store(in: &cancellables)
}
}
Interview Tip
When discussing Combine in interviews, mention thread safety: "I always use receive(on: DispatchQueue.main) before updating @Published properties to avoid main thread violations." This shows awareness of a common pitfall.