Publishers and @MainActor
Combine publishers can emit values on any thread — a network publisher built on URLSession typically delivers on a background queue, a timer publisher may fire on whatever queue it was scheduled with, and a custom publisher can emit from anywhere its underlying work happens. Since SwiftUI views and @Published properties that drive UI updates must be mutated on the main thread, correctly getting Combine values onto the main actor is one of the most important and most frequently mishandled aspects of using Combine inside a SwiftUI app. Two mechanisms address this: the Combine operator .receive(on:), and Swift concurrency's @MainActor attribute, which increasingly work together in modern codebases.
Cricket analogy: Like ball-tracking data arriving from a stadium sensor on one feed while the broadcast graphics must update on the director's main switcher, Combine publishers emit from background threads but @Published UI state must be mutated on the main thread.
receive(on:) vs. subscribe(on:)
It is easy to confuse .receive(on:) with .subscribe(on:), but they control different things: .subscribe(on:) determines which scheduler the upstream publisher's work (like the actual network call) executes on, while .receive(on:) determines which scheduler downstream operators and the final subscriber receive values on. For UI-bound pipelines, the pattern that matters is placing .receive(on: DispatchQueue.main) immediately before the operator that touches @Published state or SwiftUI, ensuring the final delivery happens on the main thread regardless of where upstream work executed.
Cricket analogy: Like deciding which broadcast truck (subscribe(on:)) captures the raw camera feed versus which control room (receive(on:)) actually cuts the footage to air, .subscribe(on:) picks where upstream work runs while .receive(on:) picks where downstream delivery happens.
final class PriceTickerViewModel: ObservableObject {
@Published private(set) var latestPrice: Double = 0
private var cancellable: AnyCancellable?
func startListening() {
cancellable = PriceFeed.shared.pricePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] price in
self?.latestPrice = price
}
}
}@MainActor and ObservableObject
Modern Swift lets you annotate an entire ObservableObject class with @MainActor, which guarantees every property and method on it — including @Published property mutations — is only ever accessed from the main actor, giving the compiler the ability to catch cross-actor misuse at compile time instead of relying on developers remembering .receive(on: .main) in every pipeline. This is generally the more robust modern approach, since a single missed .receive(on:) call in a large Combine pipeline is easy to overlook, whereas @MainActor isolation is enforced everywhere the type is used.
Cricket analogy: Like a stadium's entire control room being wired so only the main switcher can touch the live scoreboard, marking an ObservableObject @MainActor guarantees every @Published mutation happens on the main actor, catching mistakes at compile time.
@MainActor
final class PriceTickerViewModel: ObservableObject {
@Published private(set) var latestPrice: Double = 0
private var cancellable: AnyCancellable?
func startListening() {
cancellable = PriceFeed.shared.pricePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] price in
// Already isolated to the main actor by the class annotation,
// but receive(on:) still keeps the Combine chain consistent.
self?.latestPrice = price
}
}
}It helps to think of .receive(on:) and @MainActor as operating at two different layers: .receive(on:) is a Combine-level scheduling instruction that changes which dispatch queue a callback runs on, while @MainActor is a Swift-concurrency-level compile-time guarantee about which actor's exclusive context a piece of code executes in — using both together, as in the example, provides both correct runtime scheduling and compile-time safety.
Omitting .receive(on: DispatchQueue.main) before a .sink that mutates a @Published property is a classic source of intermittent bugs: the pipeline appears to work in casual testing (because the update often happens fast enough or the UI tolerates the race) but can produce data races, dropped UI updates, or purple runtime warnings under Thread Sanitizer, especially once the class is also marked @MainActor and the mismatch becomes a hard compiler error instead of a silent bug.
- Combine publishers can emit on arbitrary background threads; UI-bound state must be updated on the main thread.
.subscribe(on:)controls where upstream work executes;.receive(on:)controls where downstream values (and thus UI updates) are delivered.- Marking an
ObservableObjectwith@MainActorgives compile-time enforcement that all its properties and methods run on the main actor. @MainActorisolation is more robust than relying on remembering.receive(on: .main)in every Combine chain.- Combine's
.receive(on:)is a runtime scheduling mechanism;@MainActoris a Swift-concurrency compile-time isolation mechanism — they complement each other. - Forgetting main-thread delivery is a common source of intermittent, hard-to-reproduce UI bugs and data races.
Practice what you learned
1. What is the difference between Combine's `.subscribe(on:)` and `.receive(on:)` operators?
2. Why is `.receive(on: DispatchQueue.main)` commonly placed just before `.sink` in a Combine pipeline that updates `@Published` properties?
3. What does annotating an entire `ObservableObject` class with `@MainActor` guarantee?
4. Compared to relying solely on `.receive(on: .main)` calls scattered through Combine pipelines, why is `@MainActor` class isolation considered more robust?
5. What is a likely symptom of forgetting to deliver Combine values on the main thread before mutating `@Published` UI state?
Was this page helpful?
You May Also Like
Combine Framework Fundamentals
Understand Combine's publisher-subscriber model for handling asynchronous event streams in Swift, and how it relates to async/await and SwiftUI.
async/await Basics in Swift
Learn how Swift's async/await syntax replaces completion-handler callbacks with linear, readable asynchronous code, and how to call async functions safely.
ObservableObject and @ObservedObject
Learn how the pre-iOS 17 ObservableObject protocol and @Published/@ObservedObject let reference-type models publish changes that drive SwiftUI view updates.
Task and Structured Concurrency
Explore how Swift's Task type creates units of asynchronous work, how structured concurrency establishes parent-child relationships, and how cancellation propagates.