MainActor in Swift 6.2: Complete Guide to Thread Safety, New Features, and 2025 Best Practices
From SwiftUI crashes to seamless concurrency — everything you need to build thread-safe iOS apps in 2025

Alright, so @MainActor. You’ve probably seen it scattered throughout SwiftUI code, maybe thrown it on a few ViewModels, and wondered if you’re actually using it right. The thing is, @MainActor solves something pretty fundamental — it keeps your UI updates on the main thread where they belong. No more cryptic runtime warnings about updating UI from background threads. No more random SwiftUI crashes that only happen in production.
Actually, hang on — if the whole actor concept still feels a bit fuzzy, I wrote this comprehensive piece on Understanding Actors in Swift that covers the foundation. Worth a read if you want the full picture. But we’re focusing on @MainActor specifically here.
**Understanding Actors in Swift: Write Safer, Concurrent Code with Ease** _Confused by Swift’s new actor keyword? Learn how actors help you write safe, crash-free concurrent code — without…_swift-pal.comhttps://swift-pal.com/understanding-actors-in-swift-write-safer-concurrent-code-with-ease-cfb8ffbfa297
Swift 6.2 dropped some genuinely useful improvements that fix real problems people have been complaining about. I’m talking about stuff like finally being able to initialize @MainActor types without jumping through hoops, better debugging when things go wrong, and some syntax that doesn’t make you want to throw your MacBook out the window.
Here’s what we’re covering: fundamentals (because even senior devs sometimes miss the nuances), those new Swift 6.2 features, real SwiftUI patterns, common gotchas that’ll bite you, advanced techniques, and some interview questions that actually come up.
I am putting together a video walkthrough of all this for Swift Pal on YouTube (youtube.com/@swift-pal). Sometimes seeing the code run live just clicks better than reading about it.
**Swift Pal** _👨💻 Welcome to Swift Pal - Your iOS Dev Companion! Whether you're just starting your journey with Swift and SwiftUI…_youtube.comhttps://youtube.com/@swift-pal
Let’s dive in.
@MainActor Fundamentals
So what’s @MainActor actually doing? It’s a special actor that’s permanently tied to the main dispatch queue. Mark something with @MainActor, and Swift guarantees it runs on the main thread. Period.
Basic example:
@MainActor
class UserProfile {
var name: String = "Anonymous"
func updateName(_ newName: String) {
name = newName // Always main thread
}
}Every property, every method runs on main thread. But here’s the key difference from custom actors (check that previous article for the deep dive): @MainActor is global. One main actor for your entire app, directly mapped to UIKit’s main queue.
You can sprinkle @MainActor around pretty flexibly:
// Whole class gets it
@MainActor
class AppState: ObservableObject {
// Everything isolated
}
// Just specific methods
class NetworkManager {
@MainActor
func updateProgress(_ value: Double) {
// Only this method
}
func downloadData() -> Data {
// Runs anywhere
}
}Here’s something that confused me initially — @MainActor doesn’t magically solve all threading issues. It ensures marked code runs on main thread, eliminating data races for that code. But if you’ve got other non-isolated code touching the same data… yeah, still got problems.
Common mistake I see:
@MainActor
class Counter {
private var count = 0
func increment() {
count += 1
}
}
// Later...
let counter = Counter()
Task {
await counter.increment() // Actually safe
}
Task.detached {
await counter.increment() // Also safe
}Both Task calls work fine because they properly await the main-actor method. Compiler ensures the increment happens on main actor, even if the Task started elsewhere.
The real magic shows up with SwiftUI. Remember all those DispatchQueue.main.async blocks? Gone. @MainActor handles it.
But it’s not just convenience — it’s enforcement. Try calling @MainActor code from background without await? Compiler error. No more “oops, forgot to dispatch to main” bugs that only surface in production.
One thing that threw me: @MainActor methods become async when called from non-isolated contexts. Makes sense though — switching from background to main thread is inherently async.
@MainActor
func refreshUI() {
// UI stuff
}
// Background context
Task {
await refreshUI() // Must await
}
// From another @MainActor method
@MainActor
func anotherUIUpdate() {
refreshUI() // No await prefix needed
}This is where @MainActor beats manual queue management. Type system tracks execution context. Compiler prevents mistakes. Less crashes, more sleep.
Swift 6.2 New Features
Okay, so Swift 6.2 brought some changes that actually matter. I know, I know — every Swift release claims to have “game-changing” improvements, but these ones solve real pain points.
But first — and this is crucial if you’re upgrading from older Xcode versions — there’s a new build setting called “Default Actor Isolation” that you need to know about. In Xcode 26, new projects automatically get this set to “MainActor”, but if you’re migrating from previous Xcode versions, you’ll need to manually configure it.
Here’s how: Select your project → Target → Build Settings → search for “Default Actor Isolation” → change from “nonisolated” to “MainActor”. This setting is only available in Xcode 26, so if you don’t see it, that’s why.
This setting is what makes a lot of the new patterns work smoothly. Without it set to MainActor, some of the examples I’m about to show won’t behave as expected.
Now, onto the good stuff: nonisolated init. This one's huge. Before 6.2, initializing a @MainActor type from background contexts was... well, annoying.
// Pre-6.2 pain
@MainActor
class ProfileViewModel: ObservableObject {
let userId: String
init(userId: String) {
self.userId = userId
}
}
// Had to do this nonsense:
Task { @MainActor in
let viewModel = ProfileViewModel(userId: "123")
}Now with Default Actor Isolation set to MainActor and the nonisolated init:
@MainActor
class ProfileViewModel: ObservableObject {
let userId: String
nonisolated init(userId: String) {
self.userId = userId
}
}
// Works from anywhere
let viewModel = ProfileViewModel(userId: "123")The nonisolated keyword here means the initializer runs outside of any actor isolation. It can be called from any context without needing await.
Then there’s @concurrent for moving work off the main thread. This is specifically for offloading heavy work that would otherwise block the UI:
@MainActor
class ImageProcessor {
@concurrent
func decodeImage(data: Data) async -> UIImage {
// This runs on background thread now
return UIImage(data: data)!
}
func processImage() async {
let data = loadImageData()
let image = await decodeImage(data: data) // Runs in background
displayImage(image) // Back on main thread
}
}The @concurrent attribute tells Swift “move this function off the main actor to run in the background.” Perfect for image processing, JSON parsing, or any CPU-intensive work that would freeze your UI.
There’s also nonisolated for functions that don't need actor isolation at all:
@MainActor
class DataManager {
nonisolated func processInBackground() async -> ProcessedData {
// Flexible - runs wherever it's called from
return await heavyComputation()
}
}Nonisolated is more flexible — if you call it from main thread, it stays on main thread. If you call it from background, it stays on background. Perfect for library APIs where you want the caller to decide the execution context.
The debugging improvements in Xcode 26 are genuinely helpful too. Used to get these cryptic warnings about main thread violations. Now you get actual useful information:
// Before: "Main Thread Checker: UI API called on a background thread"
// Now: "Property 'title' of @MainActor class 'ViewModel' modified from non-isolated context in Task at line 45"Plus the Instruments integration shows which actor context your code is running in. Game changer for debugging weird concurrency issues.
The runtime diagnostics are smarter too. If you accidentally mutate @Published properties from background contexts, you get clear warnings with stack traces pointing to exactly where things went wrong.
Real-World Use Cases
Let’s get into some actual scenarios where @MainActor makes your life easier. These are patterns I see constantly in production iOS apps.
Network calls with UI updates
This is probably the most common use case. You fetch data, then update the UI:
@MainActor
@Observable
final class WeatherViewModel {
var temperature: String = "--"
var isLoading = false
func fetchWeather(for city: String) async {
isLoading = true
do {
let weather = try await WeatherService.current(for: city)
temperature = "\(weather.temp)°F"
} catch {
temperature = "Error"
}
isLoading = false
}
}No manual dispatching needed. The @MainActor ensures all those property updates happen on the main thread automatically, and @Observable handles the SwiftUI binding magic.
Background image processing
Here’s where @concurrent really shines. Heavy image work that would normally freeze your UI:
@MainActor
@Observable
final class PhotoEditor {
var editedImage: UIImage?
var isProcessing = false
@concurrent
func applyFilter(_ filter: CIFilter, to image: UIImage) async -> UIImage {
// This runs on background thread
let context = CIContext()
filter.setValue(CIImage(image: image), forKey: kCIInputImageKey)
let output = filter.outputImage!
let cgImage = context.createCGImage(output, from: output.extent)!
return UIImage(cgImage: cgImage)
}
func processPhoto(with filter: CIFilter) async {
guard let originalImage = editedImage else { return }
isProcessing = true
editedImage = await applyFilter(filter, to: originalImage)
isProcessing = false
}
}The filter processing happens in the background, but the UI state updates are safely on the main thread.
Data persistence patterns
Working with Core Data or other persistence layers:
@MainActor
@Observable
class BookmarkManager {
var bookmarks: [Bookmark] = []
nonisolated func saveBookmark(_ url: URL) async {
// Database write can happen on any thread
await DatabaseManager.shared.insert(bookmark: Bookmark(url: url))
}
func addBookmark(_ url: URL) async {
await saveBookmark(url)
// Refresh UI data on main thread
bookmarks = await DatabaseManager.shared.fetchBookmarks()
}
}The database operations run wherever they’re called from (usually background), but the UI updates happen on main thread.
Progressive loading scenarios
Like loading a list where you want to show items as they come in:
@MainActor
@Observable
final class FeedViewModel {
var posts: [Post] = []
var isLoadingMore = false
func loadInitialPosts() async {
posts = await PostService.fetchRecent(limit: 20)
}
func loadMorePosts() async {
guard !isLoadingMore else { return }
isLoadingMore = true
let newPosts = await PostService.fetchOlder(than: posts.last?.id, limit: 20)
posts.append(contentsOf: newPosts)
isLoadingMore = false
}
}Each batch of posts gets appended to the array on the main thread, so your List or ScrollView updates smoothly.
Real-time data with WebSockets
Handling live updates from WebSocket connections:
@MainActor
@Observable
final class ChatViewModel {
var messages: [Message] = []
var connectionStatus: ConnectionStatus = .disconnected
nonisolated func startListening() {
WebSocketManager.shared.onMessage = { [weak self] message in
Task { @MainActor in
self?.handleNewMessage(message)
}
}
WebSocketManager.shared.onStatusChange = { [weak self] status in
Task { @MainActor in
self?.connectionStatus = status
}
}
}
private func handleNewMessage(_ message: Message) {
messages.append(message)
}
}The WebSocket callbacks happen on background threads, but we explicitly jump to main actor for UI updates.
File upload with progress
Showing upload progress without blocking the UI:
@MainActor
@Observable
final class UploadManager {
var uploadProgress: Double = 0
var isUploading = false
@concurrent
func uploadFile(_ data: Data) async throws -> URL {
// Heavy upload work on background thread
return try await FileUploader.upload(data) { progress in
Task { @MainActor in
self.uploadProgress = progress
}
}
}
func startUpload(file: Data) async {
isUploading = true
uploadProgress = 0
do {
let url = try await uploadFile(file)
// Handle success
} catch {
// Handle error
}
isUploading = false
}
}The actual upload happens in background, but progress updates are safely delivered to the main thread for UI updates.
These patterns show up everywhere once you start using @MainActor consistently. The key insight is that you’re separating the “where should this computation happen” decision from the “where should UI updates happen” concern. UI updates always happen on main thread, but the actual work can happen wherever makes sense.
Advanced Patterns
Now let’s talk about some more sophisticated patterns that’ll really level up your @MainActor game. These are techniques I see in well-architected apps, but they’re not immediately obvious when you’re starting out.
Custom actors vs @MainActor
Sometimes you need your own actor for domain-specific isolation. Here’s when to choose what:
// Use @MainActor for UI-related state
@MainActor
@Observable
class AppUIState {
var currentScreen: Screen = .home
var isLoading = false
}
// Use custom actor for domain logic
actor DatabaseManager {
private var cache: [String: User] = [:]
func getUser(id: String) async -> User? {
if let cached = cache[id] {
return cached
}
let user = await fetchFromNetwork(id)
cache[id] = user
return user
}
}The rule of thumb? @MainActor for anything that touches UI state. Custom actors for everything else that needs isolation. (For a deep dive into custom actors, check out my comprehensive guide on Understanding Actors in Swift — it covers all the foundational concepts that make @MainActor make sense.)
Sendable conformance strategies
When you need to pass data between actors, Sendable becomes important:
struct UserData: Sendable {
let id: String
let name: String
let avatar: URL
}
@MainActor
@Observable
class UserListViewModel {
var users: [UserData] = []
func loadUsers() async {
// Safe because UserData is Sendable
users = await UserService.fetchAll()
}
}For types that can’t easily conform to Sendable, consider value types or immutable reference types:
// Instead of this mutable class
class MutableUser {
var name: String
var email: String
}
// Use this immutable struct
struct User: Sendable {
let name: String
let email: String
func withUpdatedEmail(_ newEmail: String) -> User {
User(name: name, email: newEmail)
}
}Macro integration examples
Swift macros are becoming really useful for @MainActor patterns. Here’s a custom macro that auto-applies @MainActor to ViewModels:
@UIViewModel
class ArticleListViewModel {
// Macro automatically adds @MainActor and @Observable
var articles: [Article] = []
var isLoading = false
}Or macros that add thread safety logging:
@MainActor
@Observable
@ThreadSafeLogging
class DebugViewModel {
var data: String = "" {
didSet {
// Macro adds automatic logging of thread context
}
}
}These aren’t built-in, but the community is building some really useful ones.
Testing @MainActor code
Testing requires some thought about execution context:
@MainActor
class ViewModelTests: XCTestCase {
var viewModel: ArticleListViewModel!
override func setUp() async throws {
viewModel = ArticleListViewModel()
}
func testArticleLoading() async throws {
await viewModel.loadArticles()
XCTAssertFalse(viewModel.articles.isEmpty)
}
}For testing non-UI logic that shouldn’t be on main actor:
class BusinessLogicTests: XCTestCase {
func testDataProcessing() async throws {
let processor = DataProcessor()
let result = await processor.process(largeDataSet)
XCTAssertEqual(result.count, expectedCount)
}
}Dependency injection with actors
Here’s a clean pattern for injecting dependencies into @MainActor types:
protocol ArticleServiceProtocol: Sendable {
func fetchArticles() async throws -> [Article]
}
@MainActor
@Observable
class ArticleListViewModel {
private let articleService: ArticleServiceProtocol
var articles: [Article] = []
nonisolated init(articleService: ArticleServiceProtocol) {
self.articleService = articleService
}
func loadArticles() async {
do {
articles = try await articleService.fetchArticles()
} catch {
// Handle error
}
}
}The service protocol is Sendable, so it can be safely used across actor boundaries.
Complex state coordination
When you have multiple @MainActor objects that need to coordinate:
@MainActor
@Observable
class AppCoordinator {
let userViewModel = UserViewModel()
let settingsViewModel = SettingsViewModel()
let navigationViewModel = NavigationViewModel()
func signOut() async {
await userViewModel.clearUserData()
settingsViewModel.resetToDefaults()
navigationViewModel.navigateToLogin()
}
}All coordination happens on main actor, but individual ViewModels can still do background work internally.
Memory management patterns
Be careful with retain cycles in async contexts:
@MainActor
@Observable
class TimerViewModel {
var currentTime: Date = Date()
private var timer: Timer?
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.currentTime = Date()
}
}
deinit {
timer?.invalidate()
}
}The weak self is crucial even with @MainActor — actors don’t solve retain cycles.
These patterns show up in larger, more complex apps where you’re coordinating multiple pieces of state and behavior. The key is keeping the actor boundaries clear and understanding when data needs to cross those boundaries safely.
Interview Questions & Answers
Let’s be real — @MainActor questions are showing up in iOS interviews more and more. Here are the ones I’ve seen come up, plus some tricky scenarios that’ll test your understanding.
Question 1: What’s the difference between @MainActor and DispatchQueue.main?
This is a classic. The interviewer wants to see if you understand the type system benefits:
// Old way
class OldViewModel {
var data: String = ""
func updateData(_ newData: String) {
DispatchQueue.main.async {
self.data = newData
}
}
}
// New way
@MainActor
class NewViewModel {
var data: String = ""
func updateData(_ newData: String) {
data = newData // Compiler guarantees main thread
}
}Answer: @MainActor provides compile-time guarantees that code runs on the main thread, while DispatchQueue.main is runtime-only. The compiler can catch @MainActor violations before your app ships, but DispatchQueue.main mistakes only show up when the code runs.
Question 2: What happens when you call a @MainActor method from a background thread?
@MainActor
func updateUI() {
// UI updates here
}
// Background context
Task {
updateUI() // What happens?
}Answer: This won’t compile. You need await updateUI() because calling @MainActor code from non-isolated contexts is inherently async. The await ensures the execution switches to the main actor.
Question 3: Code challenge — Fix this threading issue:
@Observable
class BrokenViewModel {
var items: [Item] = []
func loadItems() {
Task {
let newItems = await APIService.fetchItems()
self.items = newItems // Problem here
}
}
}Answer: The property assignment might not happen on main thread. Fix with @MainActor:
@MainActor
@Observable
class FixedViewModel {
var items: [Item] = []
func loadItems() async {
items = await APIService.fetchItems()
}
}Question 4: When would you use nonisolated?
Answer: When you need a method that can run in any execution context without forcing main actor isolation. Perfect for utility functions, computed properties that don’t touch actor state, or library APIs where the caller should control execution context.
@MainActor
class Calculator {
var result: Double = 0
nonisolated func add(_ a: Double, _ b: Double) -> Double {
return a + b // Pure function, no actor state needed
}
}Question 5: Explain this code and identify the issue:
@MainActor
class ViewModelWithIssue {
var data: String = ""
func expensiveOperation() {
// Simulate heavy work
Thread.sleep(forTimeInterval: 5)
data = "Processed"
}
}Answer: This blocks the main thread for 5 seconds, freezing the UI. Should use @concurrent or move the heavy work to a nonisolated method:
@MainActor
class FixedViewModel {
var data: String = ""
@concurrent
func expensiveOperation() async {
await Task.sleep(nanoseconds: 5_000_000_000) // Background work
data = "Processed" // UI update back on main
}
}Question 6: Advanced scenario — Race condition identification:
@MainActor
class CounterViewModel {
var count: Int = 0
func increment() {
count += 1
}
}
// Usage
let counter = CounterViewModel()
Task {
await counter.increment()
}
Task {
await counter.increment()
}Answer: No race condition here. Both tasks properly await the @MainActor method, so both increments happen serially on the main actor. The final count will always be 2.
Question 7: Memory management challenge:
@MainActor
class TimerViewModel {
var count = 0
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.count += 1 // Issue?
}
}
}Answer: Two issues: retain cycle (missing weak self) and potential threading issue (timer callback might not be on main thread). Fix:
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.count += 1
}
}
}Question 8: Design question — When should you NOT use @MainActor?
Answer:
- Heavy computational work that should stay on background threads
- Library code that shouldn’t force execution context on users
- Models or data types that don’t interact with UI
- Network or file I/O operations
- Anything that doesn’t need main thread guarantees
Pro tip for interviews: If they ask about actors in general, mention that you understand the broader concept (link back to custom actors) but focus on @MainActor for UI development. Most iOS interviews care more about practical UI thread safety than theoretical actor models.
The key to nailing these questions? Show that you understand @MainActor isn’t just a magic annotation — it’s about execution context, compile-time safety, and designing clear boundaries between UI and business logic
Conclusion
So there you have it — @MainActor in all its Swift 6.2 glory. We’ve covered everything from the basics to those tricky interview scenarios that’ll make you look like you actually know what you’re talking about in code reviews.
The thing about @MainActor is that once you really get it, it fundamentally changes how you think about iOS app architecture. You stop worrying about “did I remember to dispatch to main?” and start thinking about “what execution context does this code actually need?” That’s a much healthier mental model.
Swift 6.2’s improvements — especially that Default Actor Isolation setting and nonisolated init — solve real problems that were genuinely annoying in earlier versions. No more jumping through hoops to initialize ViewModels. No more weird compiler errors that made you question your life choices.
If you’re coming from the old DispatchQueue.main.async world, the transition might feel a bit overwhelming at first. Start small. Pick one ViewModel, add @MainActor and @Observable, see how it feels. Then gradually expand outward. Don’t try to refactor your entire codebase in one weekend — I’ve seen that go badly.
The patterns we covered — especially the separation between UI state (@MainActor) and business logic (custom actors or nonisolated) — these show up everywhere in well-designed apps. Master these, and you’ll write more maintainable, safer concurrent code.
Want to understand the full picture of Swift’s actor model? Definitely check out my deep dive on Understanding Actors in Swift at https://medium.com/swift-pal/understanding-actors-in-swift-write-safer-concurrent-code-with-ease-cfb8ffbfa297. It covers the foundational concepts that make @MainActor click into place.
**Understanding Actors in Swift: Write Safer, Concurrent Code with Ease** _Confused by Swift’s new actor keyword? Learn how actors help you write safe, crash-free concurrent code — without…_swift-pal.comhttps://swift-pal.com/understanding-actors-in-swift-write-safer-concurrent-code-with-ease-cfb8ffbfa297
And speaking of deep dives — I’m putting together a comprehensive video walkthrough of everything we covered today for the Swift Pal YouTube channel. You can find it at https://youtube.com/@swift-pal, where I’ll code through these patterns live and show you exactly how they work in real projects.
**Swift Pal** _👨💻 Welcome to Swift Pal - Your iOS Dev Companion! Whether you're just starting your journey with Swift and SwiftUI…_youtube.comhttps://youtube.com/@swift-pal
The future of iOS development is concurrent by default, UI-safe by design. @MainActor is a big part of that future. Get comfortable with it now, and you’ll be ahead of the curve.
Now go build something awesome. And remember — if your UI updates are happening on the wrong thread, @MainActor has your back.
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read