100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Swift

Publishers and @MainActor

Learn how to safely deliver Combine publisher values to the main thread using @MainActor and .receive(on:), and how actor isolation interacts with reactive pipelines.

Concurrency & CombineAdvanced10 min readJul 8, 2026
Analogies

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.

swift
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.

swift
@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 ObservableObject with @MainActor gives compile-time enforcement that all its properties and methods run on the main actor.
  • @MainActor isolation is more robust than relying on remembering .receive(on: .main) in every Combine chain.
  • Combine's .receive(on:) is a runtime scheduling mechanism; @MainActor is 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

Was this page helpful?

Topics covered

#Swift#IOSWithSwiftUIStudyNotes#MobileDevelopment#PublishersAndMainActor#Publishers#MainActor#Receive#Subscribe#StudyNotes#SkillVeris