Advanced Concurrency

Schedulers and Threading in Combine

August 17, 2026
5 min read
Featured image for blog post: 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch