Introduction

PassthroughSubject in Combine does not guarantee thread safety. When send() is called from a background thread, all downstream subscribers receive values on that same background thread. If a subscriber updates UI (like @Published properties or SwiftUI views) from a background thread, it causes undefined behavior, thread sanitizer warnings, or runtime crashes. This is common when network responses trigger send() from a background URLSession callback queue.

Symptoms

  • Thread Sanitizer warning: Publishing changes from background threads is not allowed
  • SwiftUI view updates on background thread causing visual glitches
  • @Published property modified off main thread
  • Random crashes in UI code triggered by Combine pipeline
  • MainActor assertion failure in Swift 5.5+

Thread Sanitizer warning: `` [Combine] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

Common Causes

  • PassthroughSubject.send() called from URLSession callback queue
  • Background queue dispatch triggers send without scheduler switching
  • No .receive(on:) operator before UI-bound subscriber
  • @MainActor not enforced on ObservableObject
  • Multiple subjects sending from different threads

Step-by-Step Fix

  1. 1.Use receive(on:) to switch to main thread before UI update:
  2. 2.```swift
  3. 3.import Combine

class ViewModel: ObservableObject { @Published var items: [Item] = []

private let subject = PassthroughSubject<[Item], Never>() private var cancellables = Set<AnyCancellable>()

init() { subject // IMPORTANT: Switch to main thread before updating @Published .receive(on: DispatchQueue.main) .assign(to: &$items)

// Or with sink: subject .receive(on: RunLoop.main) .sink { [weak self] items in self?.items = items } .store(in: &cancellables) }

func loadData() { // This runs on a background queue URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in guard let data = data, let items = try? JSONDecoder().decode([Item].self, from: data) else { return }

// WRONG - sends from URLSession background queue // self?.subject.send(items)

// CORRECT - dispatch to main thread before sending DispatchQueue.main.async { self?.subject.send(items) } }.resume() } } ```

  1. 1.**Use CurrentValueSubject with @MainActor for automatic main thread enforcement":
  2. 2.```swift
  3. 3.import Combine

@MainActor class MainActorViewModel: ObservableObject { @Published var username: String = ""

// Subject is also on MainActor private let usernameSubject = CurrentValueSubject<String, Never>("")

private var cancellables = Set<AnyCancellable>()

init() { // Since this class is @MainActor, all subscriptions run on main thread usernameSubject .removeDuplicates() .debounce(for: .milliseconds(300), scheduler: RunLoop.main) .assign(to: &$username) }

func updateUsername(_ name: String) { // @MainActor ensures this runs on main thread usernameSubject.send(name) } } ```

  1. 1.**Create a thread-safe subject wrapper":
  2. 2.```swift
  3. 3.import Combine

/// A PassthroughSubject that ensures send() always happens on the specified scheduler final class MainThreadSubject<Output, Failure: Error>: ObservableObject { private let subject = PassthroughSubject<Output, Failure>() private let scheduler: DispatchQueue

var publisher: AnyPublisher<Output, Failure> { subject.eraseToAnyPublisher() }

init(scheduler: DispatchQueue = .main) { self.scheduler = scheduler }

func send(_ value: Output) { if Thread.isMainThread { subject.send(value) } else { scheduler.async { [weak self] in self?.subject.send(value) } } }

func send(completion: Subscribers.Completion<Failure>) { if Thread.isMainThread { subject.send(completion: completion) } else { scheduler.async { [weak self] in self?.subject.send(completion: completion) } } } }

// Usage let subject = MainThreadSubject<[Item], Never>() subject.send(items) // Always sends on main thread, safe to call from anywhere ```

Prevention

  • Always use .receive(on: DispatchQueue.main) before UI-bound subscriptions
  • Mark ObservableObject classes with @MainActor on iOS 15+
  • Dispatch to main thread before calling send() from background callbacks
  • Enable Thread Sanitizer in Debug scheme to catch thread violations
  • Use MainRunLoop or DispatchQueue.main scheduler for debounce/throttle operators
  • Document threading expectations for all Combine publishers in the codebase