SwiftData vs Core Data in a 120Hz Sudoku Engine: A Concurrency Case Study
Building a Sudoku app sounds simple until the app stops being a static puzzle viewer.
Building a Sudoku app sounds simple until the app stops being a static puzzle viewer.
In SwiftSuDoKu, I wanted three things at the same time:
- A SwiftUI board that stayed responsive at 120Hz on ProMotion devices.
- A background generator that could create difficult puzzles without blocking the main thread.
- A local database that kept puzzle history, drafts, streaks, and challenge queues consistent.
The first version used Core Data because Core Data is proven, powerful, and familiar. The problem was not that Core Data could not handle the app. The problem was that the concurrency model made the app easier to get subtly wrong.
SwiftData did not magically solve persistence. It changed the shape of the code enough that the concurrency boundary became visible.
The bug that made the migration worth considering
The crash was intermittent:
EXC_BAD_ACCESS
The real bug was not mysterious. A background generation path created or touched Core Data objects, and a later UI path assumed it could read the same object graph on the main context. Anyone who has shipped Core Data knows the rule: do not pass managed objects across context boundaries. Pass object IDs or value objects.
The fact that I knew the rule did not prevent the bug. That is exactly the kind of architecture smell I pay attention to. If the safe path depends on remembering a rule in every feature branch, the system is fragile.
The unsafe pattern looked like this:
func generatePuzzleInBackground() {
backgroundContext.perform {
let puzzle = SudokuPuzzleEntity(context: backgroundContext)
puzzle.id = UUID()
puzzle.difficulty = 4
puzzle.initialBoard = generatedBoardData
try? backgroundContext.save()
DispatchQueue.main.async {
self.selectedPuzzle = puzzle
}
}
}
That code is obviously wrong when isolated in an article. In a real app, the object moved through helpers, callbacks, and view models. The bug became easy to miss.
The safe Core Data version passes NSManagedObjectID:
let objectID = puzzle.objectID
DispatchQueue.main.async {
let mainContext = PersistenceController.shared.container.viewContext
let safePuzzle = mainContext.object(with: objectID) as? SudokuPuzzleEntity
self.selectedPuzzle = safePuzzle
}
This works, but it also reveals the friction. The app's domain logic becomes mixed with persistence identity management.
The SwiftData model
The SwiftData version started with a simpler domain shape:
import SwiftData
@Model
final class SudokuPuzzle {
@Attribute(.unique) var id: UUID
var difficulty: Int
var initialBoard: [Int]
var solutionBoard: [Int]
var createdAt: Date
var bestTime: TimeInterval?
var isChallengePuzzle: Bool
init(
id: UUID = UUID(),
difficulty: Int,
initialBoard: [Int],
solutionBoard: [Int],
isChallengePuzzle: Bool = false
) {
self.id = id
self.difficulty = difficulty
self.initialBoard = initialBoard
self.solutionBoard = solutionBoard
self.createdAt = .now
self.isChallengePuzzle = isChallengePuzzle
}
}
The background generator does not create objects for the UI to hold directly. It creates persistent records inside its own actor-owned context.
actor PuzzleGenerationStore {
private let container: ModelContainer
init(container: ModelContainer) {
self.container = container
}
func generateBatch(count: Int, difficulty: Int) async throws {
let context = ModelContext(container)
for _ in 0..<count {
let puzzle = try SudokuGenerator.generate(difficulty: difficulty)
context.insert(
SudokuPuzzle(
difficulty: difficulty,
initialBoard: puzzle.initial,
solutionBoard: puzzle.solution,
isChallengePuzzle: true
)
)
}
try context.save()
}
}
The UI reads through SwiftData's query system:
struct ChallengeQueueView: View {
@Query(
filter: #Predicate<SudokuPuzzle> { $0.isChallengePuzzle },
sort: \SudokuPuzzle.createdAt,
order: .reverse
)
private var puzzles: [SudokuPuzzle]
var body: some View {
List(puzzles) { puzzle in
PuzzleRow(puzzle: puzzle)
}
}
}
That separation is the real win. The generator owns generation. The view owns presentation. The persistence layer becomes a synchronization surface instead of a bag of objects passed around the app.
Performance constraints in a puzzle app
Sudoku generation can be surprisingly expensive if the app requires uniqueness, difficulty grading, and a pleasant solve curve. A naive generator might create a valid board quickly, but grading difficulty can involve repeated solving, backtracking, candidate elimination, and heuristic scoring.
In my measurements, hard puzzle generation could easily consume 80ms-250ms per candidate depending on the device and grading rules. That is fatal on the main thread.
My working budget looked like this:
| Work item | Target budget | Main thread allowed |
|---|---|---|
| Board tap handling | under 4ms | yes |
| Candidate highlighting | under 8ms | yes |
| Puzzle save | under 20ms visible work | no if batch |
| Hard puzzle generation | 80ms-250ms | no |
| Challenge queue refill | background | no |
SwiftData did not make generation faster. It made the concurrency story clearer so generation stayed away from the UI.
Where SwiftData is not a free upgrade
I would not describe SwiftData as "Core Data but better" in every case. Core Data still gives mature tooling, explicit migration control, advanced fetch tuning, and years of operational knowledge. If I were maintaining a large existing Core Data app, I would not rewrite it for fashion.
For this app, the migration made sense because:
- The data model was small.
- The app was SwiftUI-first.
- The background write paths were easy to isolate.
- I wanted Swift concurrency boundaries to be visible in the code.
- The UI benefited from
@Queryand simpler model declarations.
The migration would be less obvious for a large relational model, complex migrations, heavy batch operations, or a team with deep Core Data infrastructure.
The failed approach: serializing everything
Before migrating, I tried to make Core Data safe by serializing too much work:
context.performAndWait {
// generate, grade, save, refresh
}
This reduced some crashes but made the app feel worse. It moved me from a correctness bug to a responsiveness bug. That is not a win.
The correct boundary is not "one queue for everything." The correct boundary is:
- Pure generation can run independently.
- Persistence writes happen in an isolated context.
- UI reads are reactive and main-actor safe.
- The app passes value summaries or persistent identity, not live mutable objects.
SwiftData made that boundary easier to encode.
The offline-first lesson
This Sudoku case influenced how I build local AI features too. A local model app has the same shape: background work creates derived artifacts, the database stores them, and SwiftUI reacts.
For example, a note app with on-device AI can analyze notes in a background actor and persist tags:
actor NoteInsightWorker {
let container: ModelContainer
let model: LocalModelSession
func analyze(noteID: UUID) async throws {
let context = ModelContext(container)
let note = try fetchNote(id: noteID, in: context)
let tags = try await model.extractTags(from: note.body)
note.aiTags = tags
try context.save()
}
}
The UI does not wait for the model. It observes stored results. That is the same pattern as the puzzle queue.
The engineering conclusion
The reason I moved SwiftSuDoKu from Core Data to SwiftData was not that Core Data failed. It was that the code I wanted to write was actor-oriented, SwiftUI-first, and small enough that SwiftData's ergonomics improved both correctness and readability.
The real lesson is broader: persistence architecture is part of UI performance. If the database boundary lets background work leak into view state, the app will eventually stutter or crash. If the boundary is explicit, the UI can stay smooth while the system keeps working behind it.