SwiftData updates in the background are not merged in the main UI context

Hello,

SwiftData is not working correctly with Swift Concurrency. And it’s sad after all this time.

I personally found a regression. The attached code works perfectly fine on iOS 17.5 but doesn’t work correctly on iOS 18 or iOS 18.1. A model can be updated from the background (Task, Task.detached or ModelActor) and refreshes the UI, but as soon as the same item is updated from the View (fetched via a Query), the next background updates are not reflected anymore in the UI, the UI is not refreshed, the updates are not merged into the main.

How to reproduce:

  • Launch the app
  • Tap the plus button in the navigation bar to create a new item
  • Tap on the “Update from Task”, “Update from Detached Task”, “Update from ModelActor” many times
  • Notice the time is updated
  • Tap on the “Update from View” (once or many times)
  • Notice the time is updated
  • Tap again on “Update from Task”, “Update from Detached Task”, “Update from ModelActor” many times
  • Notice that the time is not update anymore

Am I doing something wrong? Or is this a bug in iOS 18/18.1?

Many other posts talk about issues where updates from background thread are not merged into the main thread. I don’t know if they all are related but it would be nice to have

1/ bug fixed, meaning that if I update an item from a background, it’s reflected in the UI, and 2/ proper documentation on how to use SwiftData with Swift Concurrency (ModelActor). I don’t know if what I’m doing in my buttons is correct or not.

Thanks, Axel

import SwiftData
import SwiftUI

@main
struct FB_SwiftData_BackgroundApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: Item.self)
        }
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    
    @State private var simpleModelActor: SimpleModelActor!
    @Query private var items: [Item]
    
    var body: some View {
        NavigationView {
            VStack {
                if let firstItem: Item = items.first {
                    Text(firstItem.timestamp, format: Date.FormatStyle(date: .omitted, time: .standard))
                        .font(.largeTitle)
                        .fontWeight(.heavy)
                    
                    Button("Update from Task") {
                        let modelContainer: ModelContainer = modelContext.container
                        let itemID: Item.ID = firstItem.persistentModelID
                        
                        Task {
                            let context: ModelContext = ModelContext(modelContainer)
                            
                            guard let itemInContext: Item = context.model(for: itemID) as? Item else { return }
                            
                            itemInContext.timestamp = Date.now.addingTimeInterval(.random(in: 0...2000))

                            try context.save()
                        }
                    }
                    .buttonStyle(.bordered)
                    
                    Button("Update from Detached Task") {
                        let container: ModelContainer = modelContext.container
                        let itemID: Item.ID = firstItem.persistentModelID
                        
                        Task.detached {
                            let context: ModelContext = ModelContext(container)
                            
                            guard let itemInContext: Item = context.model(for: itemID) as? Item else { return }
                            
                            itemInContext.timestamp = Date.now.addingTimeInterval(.random(in: 0...2000))
                            
                            try context.save()
                        }
                    }
                    .buttonStyle(.bordered)
                    
                    Button("Update from ModelActor") {
                        let container: ModelContainer = modelContext.container
                        let persistentModelID: Item.ID = firstItem.persistentModelID
                        
                        Task.detached {
                            let actor: SimpleModelActor = SimpleModelActor(modelContainer: container)
                            await actor.updateItem(identifier: persistentModelID)
                        }
                    }
                    .buttonStyle(.bordered)
                    
                    Button("Update from ModelActor in State") {
                        let container: ModelContainer = modelContext.container
                        let persistentModelID: Item.ID = firstItem.persistentModelID
                        
                        Task.detached {
                            let actor: SimpleModelActor = SimpleModelActor(modelContainer: container)
                            
                            await MainActor.run {
                                simpleModelActor = actor
                            }
                            
                            await actor.updateItem(identifier: persistentModelID)
                        }
                    }
                    .buttonStyle(.bordered)
                    
                    Divider()
                        .padding(.vertical)
                
                    Button("Update from View") {
                        firstItem.timestamp = Date.now.addingTimeInterval(.random(in: 0...2000))
                    }
                    .buttonStyle(.bordered)
                } else {
                    ContentUnavailableView(
                        "No Data",
                        systemImage: "slash.circle", // 􀕧
                        description: Text("Tap the plus button in the toolbar")
                    )
                }
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }
    
    private func addItem() {
        modelContext.insert(Item(timestamp: Date.now))
        try? modelContext.save()
    }
}

@ModelActor
final actor SimpleModelActor {
    var context: String = ""

    func updateItem(identifier: Item.ID) {
        guard let item = self[identifier, as: Item.self] else {
            return
        }

        item.timestamp = Date.now.addingTimeInterval(.random(in: 0...2000))
        
        try! modelContext.save()
    }
}

@Model
final class Item: Identifiable {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

This is probably for the best. If you think about it, why bother to make a whole new context and a new item object just to save if you want the outcome to be another instance of the object loaded back into the main context? Why not just save the memory and use the main context in the first place so there is only one object instance. The main context save happens semi-asynchronously after 10 seconds or at app suspend anyway.

If many objects were saved in the background ModelActor and automatically loaded into main context regardless if they are shown on the UI or not then it would be just as slow and memory intensive as if they were saved to the main context in the first place, perhaps even slower or twice as memory intensive because they have been loaded into 2 contexts.

SwiftData updates in the background are not merged in the main UI context
 
 
Q