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
@Publishedproperty modified off main thread- Random crashes in UI code triggered by Combine pipeline
MainActorassertion 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 @MainActornot enforced on ObservableObject- Multiple subjects sending from different threads
Step-by-Step Fix
- 1.Use receive(on:) to switch to main thread before UI update:
- 2.```swift
- 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.**Use CurrentValueSubject with @MainActor for automatic main thread enforcement":
- 2.```swift
- 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.**Create a thread-safe subject wrapper":
- 2.```swift
- 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
@MainActoron iOS 15+ - Dispatch to main thread before calling
send()from background callbacks - Enable Thread Sanitizer in Debug scheme to catch thread violations
- Use
MainRunLooporDispatchQueue.mainscheduler for debounce/throttle operators - Document threading expectations for all Combine publishers in the codebase