Swift Publishing From Background Threads Isn't Allowed
Route published writes through @MainActor, MainActor.run, or receive(on:).
20+ years shipping production backend systems. Everything here is grounded in real deployments.
- ✓Basic SwiftUI state and ObservableObject
- ✓Closures and async network calls
- ✓Intro to Combine or async await
- SwiftUI reads @Published properties on the main thread, so writing them from a background thread races the UI and triggers the warning
- Mark UI-bound view models @MainActor so every assignment hops to the main thread automatically
- Wrap one-off assignments in MainActor.run inside URLSession or GCD callbacks
- Add receive(on: DispatchQueue.main) before Combine sinks, or use Task with @MainActor for async work
Think of the screen as a whiteboard one artist redraws constantly. Your network code is a second artist scribbling new numbers on the same board mid-stroke. The result is smeared digits nobody trusts. The main actor is a simple rule: only one artist touches the board, and everyone else slides notes under the door. Marking the board for one artist is @MainActor, and sliding finished notes under is MainActor.run.
You're scrolling a list that just loaded when Xcode paints a purple banner: publishing changes from background threads is not allowed. The app didn't crash, the data looks right, and the warning vanishes when you scroll again. It's tempting to ignore it, until the same screen starts dropping updates and glitching under load.
The warning means a @Published property changed on a background thread while SwiftUI was reading it on the main thread. Views observe those properties and redraw on the main runloop. A write from a network callback or a global queue races that read, producing torn values, missed refreshes, and occasional crashes that never reproduce in the debugger.
Modern Swift made this more visible, not more common. URLSession completions, Combine schedulers, and unstructured Tasks all run off the main thread by default. The moment any of them assigns directly to view-model state, the race is on. The code compiles because nothing in the syntax marks the hop you skipped.
This guide shows you how to route every UI-bound write through @MainActor, MainActor.run, receive(on:), or Task correctly. You'll keep heavy work on background threads where it belongs while guaranteeing the final assignment lands on the main actor every time.
Why Published Writes Must Hit the Main Thread
The rule is absolute: any write to a @Published property that SwiftUI observes must execute on the main thread. SwiftUI's view graph reads those values during layout and rendering on the main runloop. A write from any other thread overlaps that read, and the overlap corrupts the value mid-draw. Xcode's purple warning is the runtime catching the overlap in the act.
Background threads reach your state through ordinary code. URLSession completion handlers run on delegate queues, DispatchQueue.global().async runs on worker pools, Combine pipelines deliver on whatever scheduler decoded last, and detached Tasks inherit no actor at all. Each path compiles cleanly because the assignment syntax looks identical regardless of thread. The thread is invisible in the source and decisive at runtime.
Reproducing the warning takes slow or repeated work: fast Wi-Fi often wins the race silently, which is why simulator testing misses it. Throttle the network, scroll during loads, and refresh rapidly. The purple banner appears in the issue navigator with the property name and a backtrace naming the offending queue. That backtrace is your fix location.
The fix family is small. Route writes through the main actor with @MainActor, MainActor.run, receive(on:), or a main-actor Task, while leaving parsing and waiting on background threads. Prove the fix by re-running the throttled scenario until the warning disappears, then keep it gone with strict concurrency diagnostics in CI.
@MainActor View Models That Can't Warn
Annotating a view model with @MainActor isolates the whole type to the main thread. Every method runs there, every property access hops there, and the compiler inserts the hops automatically at call sites. Background code that calls a @MainActor method suspends, runs the method on the main thread, and resumes. The warning class disappears by construction rather than by vigilance.
Keep @MainActor methods thin. Fetching, decoding, sorting thousands of rows, and waiting on semaphores don't belong on the main thread even when the method hosting them is isolated there. Split the work: a nonisolated or background async function produces values, and a tiny @MainActor method assigns them. The main thread should only ever receive finished values and store them.
Calling into @MainActor from synchronous background closures needs care. A GCD or delegate callback can't await directly, so wrap the call in Task { @MainActor in ... } to hop. From async contexts just await the method or the MainActor.run block. The compiler guides you: if it demands await, you're crossing an actor boundary, which is exactly the hop the warning wanted.
Apply @MainActor at the type level for UI-bound models, not scattered per method. A single annotation covers future properties and methods automatically, so the next developer can't add an unprotected write. Reserve method-level annotations for mixed types where only the UI-facing parts need isolation.
MainActor.run for Single Assignments in Callbacks
MainActor.run executes a closure on the main actor and is the right tool for single assignments inside legacy callbacks. Parse the payload on the background thread first, then hop with only the finished values. The closure should contain assignments and light UI bookkeeping, never decoding loops or network waits that would stall the main thread.
From async code, await MainActor.run directly and the task resumes after the write lands. From synchronous callbacks like GCD or delegate methods, wrap the hop in Task { @MainActor in ... } since those contexts can't await. Both forms guarantee the write executes on the main thread while the surrounding work stays where it was.
Capture semantics still apply. Use [weak self] in escaping closures so a dismissed screen doesn't get resurrected by a late response. Inside the hop, guard the reference and skip the assignment when the view model is gone. A late write to a dead screen is harmless only when nobody observes it anymore.
Don't nest hops or chain them in loops. One hop per response keeps ordering clear: background produces, main assigns, done. If several properties update together, assign them all inside the same closure so observers see one consistent state instead of a sequence of half-updated snapshots.
receive(on:) Placement in Combine Pipelines
Combine pipelines choose their threads explicitly, and the default is rarely the main thread. subscribe(on:) moves upstream subscription work, operators like map and decode run wherever the previous step delivered, and sink receives on that same scheduler unless told otherwise. A pipeline decoding on a background queue assigns into @Published from that queue, which is exactly the warned race.
receive(on: DispatchQueue.main) placed directly before the sink moves delivery onto the main queue. Upstream operators keep their background performance: decoding thousands of articles stays off the main thread, and only the finished array crosses over. Order matters: a receive(on:) buried early gets overridden by later operators that hop away again, so keep it last before the UI sink.
This differs from subscribe(on:), which many developers add hoping it fixes delivery. It doesn't: subscribe(on:) affects where subscription side effects run, not where values arrive. You often want both, subscribe on background for the work and receive on main for the results, and confusing them is why the warning survives the first fix attempt.
Audit every pipeline that terminates in a UI-bound sink or assign. Search for .sink and .assign subscribers touching view-model state and verify a main receive sits above each. Better, build shared publisher factories that include the hop, so individual screens can't forget it. The warning then becomes unrepresentable in new code.
Task Structure That Keeps UI Writes Safe
Structured Tasks created with Task { ... } from a @MainActor context inherit that isolation, which makes them the natural replacement for DispatchQueue.global().async. The body runs cooperatively, awaits background helpers without blocking threads, and hops back for assignments. Cancellation propagates through the task tree, so disappearing screens stop feeding dead view models.
Detached tasks break every one of those guarantees. Task.detached inherits no actor, captures no cancellation, and runs on the global pool. Code inside touches published state from an arbitrary thread, which is why detached UI updates warn under stress. Reserve detached tasks for truly independent background computation that never touches the UI, and even there prefer structured alternatives.
Model the refresh lifecycle explicitly. Store the in-flight Task, cancel it when the view disappears or a new refresh starts, and check Task.isCancelled before assigning. Stale responses then die quietly instead of overwriting fresher data. The main-actor hop stays, but it only runs for winners.
Migration from GCD follows one pattern: replace async(group:) bodies with Tasks, replace sync hops with awaits, and replace dispatch-to-main with MainActor.run. Each conversion removes a queue name developers had to remember and replaces it with isolation the compiler checks. The codebase gets shorter and the warning gets rarer with every file converted.
Proving the Fix With Tests and Profiler Runs
A fix that works on your desk can regress in a week without guardrails. Enable strict concurrency checking in build settings so missing hops surface as warnings in every build, and escalate them to errors in CI. Warnings developers see locally get fixed; warnings only CI sees get fixed faster.
Add a UI test that loads the feed over a throttled connection while scrolling, with the test runner configured to fail on runtime warnings. This reproduces the exact race that simulators hide: background delivery colliding with main-thread rendering. When the test passes consistently, the pipeline ordering is proven, not hoped.
Run thread sanitizer over view-model unit tests that drive fetch, refresh, and cancel sequences. Sanitizer catches races that the main-thread diagnostic misses, especially around caches and pagination cursors feeding the same published array. Fix each report by moving shared mutation onto the actor rather than adding locks around UI state.
Finally, review the split between background and main in every view model once per release. Time Profiler should show parsing, sorting, and networking off the main thread and assignments on it. When a screen hitches, the profile tells you whether work leaked onto main or writes leaked off it. Either leak has a known fix, and the profile points at which one you need.
The Commute-Hour Feed That Ate Its Own Stories
- Purple threading warnings are production bugs with a delay fuse. Triage them like crashes, not suggestions, the day they appear.
- Previews and simulators hide scheduler races. Verify feed screens on devices with throttled networks before every release.
- Push scheduler hops into shared templates and base classes so no individual pipeline can forget receive(on:) again.
| File | Command / Code | Purpose |
|---|---|---|
| FeedViewModel.swift | final class FeedViewModel: ObservableObject { | Why Published Writes Must Hit the Main Thread |
| FeedViewModel.swift | @MainActor | @MainActor View Models That Can't Warn |
| MainActorHop.swift | func handleResponse(_ data: Data) { | MainActor.run for Single Assignments in Callbacks |
| FeedPipeline.swift | cancellable = API.articlesPublisher() | receive(on |
| FeedTasks.swift | func refresh() { | Task Structure That Keeps UI Writes Safe |
Key takeaways
Common mistakes to avoid
5 patternsAssigning @Published properties inside URLSession callbacks
Mutating observable state from DispatchQueue.global()
DispatchQueue.global().async bodies that touch state with Task blocks, and mark UI-mutating functions @MainActor. Let the compiler enforce the hop instead of remembering it.Forgetting receive(on:) before sink in Combine pipelines
Marking an entire view model nonisolated to dodge warnings
Asserting published values in tests without main-actor isolation
Interview Questions on This Topic
Why must @Published updates happen on the main thread?
Frequently Asked Questions
20+ years shipping production backend systems. Everything here is grounded in real deployments.
That's Swift. Mark it forged?
6 min read · try the examples if you haven't