Stop Using @MainActor as a Threading Fix for Core Data async/await
Using `@MainActor` to silence Core Data concurrency errors keeps service-layer work on the main thread. Learn how `newBackgroundContext()` and `context.perform` create a safer async/await pattern.
Adding async/await to a Core Data service often exposes an awkward transition point. Existing methods that read from viewContext start producing concurrency errors, and the fastest way to silence them is to mark those methods @MainActor.
That makes the compiler happy, but it does not improve the threading model. viewContext is still main-thread-bound, so that work still runs on the same thread as rendering and input, which means the UI can stall while the service method runs.
For service-layer code, a better pattern is to create a fresh background context for each operation and run the full unit of work inside try await context.perform { }. That keeps Core Data work off the main thread and prevents one operation’s unsaved state from leaking into the next.
Why @MainActor Is the Wrong Fix
The compiler error appears for a real reason. viewContext is main-thread-bound. If a service method touches it, Swift Concurrency needs to know that access is isolated to the main actor.
Actors are a Swift Concurrency feature that serialize access to shared state. The main actor is a special global actor that represents the main thread.
The tempting “fix” is to just make the service method main-actor isolated:
struct TransactionService {
let container: NSPersistentContainer
@MainActor
func transactions(for accountID: NSManagedObjectID) throws -> [Transaction] {
let context = container.viewContext
let account = try context.existingObject(with: accountID) as! AccountEntity
let entities = account.transactions?.allObjects as? [TransactionEntity] ?? []
return entities
.map(Transaction.init)
.sorted { $0.date > $1.date }
}
}
This compiles because @MainActor forces the method onto the main actor, where viewContext is safe to access.
However, this comes with a tradeoff: you’ve moved the service method onto the main actor.
- Any caller now has to hop to (and potentially wait for) the main actor.
- The Core Data work itself runs on the main thread while it executes.
That can translate into dropped frames or input lag when the fetch happens during a screen load, search, or a chain of dependent service calls.
How Background Contexts and context.perform Fix the Problem
The correct fix is to stop using viewContext for service operations that do not belong on the main thread.
newBackgroundContext() creates a private-queue context. That means Core Data expects all work for that context to be scheduled onto its own queue rather than run directly from whatever thread called the service method.
That is what context.perform { } does. It hands Core Data a closure and says, “run this work on the context’s queue.” In Swift Concurrency, you can await that handoff directly with try await context.perform { }. The closure runs on the background context’s private queue, and the caller suspends until the work finishes and returns a result.
That gives you this shape instead:
struct TransactionService {
let persistenceController: PersistenceController
func transactions(for accountURI: String) async throws -> [Transaction] {
let context = persistenceController.newBackgroundContext()
return try await context.perform {
let request = try FetchRequestBuilder.account(byURIString: accountURI)
guard let account = try context.fetch(request).first else {
return []
}
let entities = account.transactions?.allObjects as? [TransactionEntity] ?? []
return entities
.map(Transaction.init)
.sorted { $0.date > $1.date }
}
}
}
No @MainActor annotation is needed because the method no longer touches viewContext. The Core Data work runs on the background context’s queue, and the caller just awaits the result.
Swift 6.2: execution does not leave the main actor just because a function is marked
async. It leaves the current actor when the code reaches an actual suspension point. In this pattern, that handoff happens atawait context.perform { }, not at the top of the method.
That is why replacing @MainActor with async alone is not enough. The off-main transition has to be explicit.
Why Each Operation Should Get Its Own Context
Once the code moves to background contexts, the next tempting optimization is to create one shared background context and reuse it across all service methods.
That usually looks cleaner at first:
struct TransactionService {
let persistenceController: PersistenceController
let context: NSManagedObjectContext
init(persistenceController: PersistenceController) {
self.persistenceController = persistenceController
self.context = persistenceController.newBackgroundContext()
}
func update(transaction: Transaction) async throws {
try await context.perform {
// mutate objects
// save later
}
}
}
The problem is that a managed object context is stateful. If one perform block inserts or updates objects and then throws before saving, those unsaved changes remain in the context. The next operation that reuses that same context inherits the dirty state from the previous one. That is the kind of bug that produces confusing balance mismatches, extra saves, and object graph side effects that are very hard to find.
The safer pattern is to create a fresh context for each operation:
struct TransactionService {
let persistenceController: PersistenceController
func update(transaction updatedTransaction: Transaction, originalAccountID: String) async throws {
let context = persistenceController.newBackgroundContext()
try await context.perform {
let transaction = try loadTransaction(updatedTransaction.id, in: context)
transaction.amount = updatedTransaction.amount
transaction.memo = updatedTransaction.memo
let oldAccount = try loadAccount(originalAccountID, in: context)
let newAccount = try loadAccount(updatedTransaction.accountID, in: context)
if oldAccount != newAccount {
oldAccount.removeFromTransactions(transaction)
try oldAccount.recalculateBalances()
newAccount.addToTransactions(transaction)
try newAccount.recalculateBalances()
} else {
try newAccount.recalculateBalances()
}
try persistenceController.save(context: context)
}
}
}
Each method gets a clean working set, performs all related changes, saves, and then discards the context by letting it fall out of scope. If the operation fails, the unsaved state dies with that context instead of contaminating the next call.
There is a small overhead to allocating a context per operation. In most apps, that overhead is negligible compared to the clarity and safety you get back. The main win though is atomicity. If an update has to move a transaction from one account to another and recalculate both balances, all of that work can live inside one context.perform block and one save. Either both balance updates persist, or neither does.
Conclusion
@MainActor on a Core Data service method is not a threading fix. It just makes the compiler stop complaining by forcing the work onto the main thread.
If you are refactoring an existing service layer, the migration is usually straightforward. Start with this checklist:
- Remove
@MainActorfrom service methods - Replace
viewContextwithnewBackgroundContext()inside each method - Wrap the full operation in
try await context.perform { } - Write tests to verify the behavior before and after the change
That third step is the important one. The real change is not just making the method async. It is making the context boundary explicit, so the work runs on the right queue and the service no longer depends on the main thread to function correctly.