I spent part of this week fixing a mobile AI feature where the model code was not the problem.
The app has a small on-device personalization loop: collect daily logs, merge in HealthKit metrics, retrain a local model periodically, and use that state to drive predictions and nudges. Nothing huge. Just the kind of background ML plumbing that makes a feature feel alive instead of static.
The bug was simpler and more annoying:
I had registered the background tasks.
I had handlers for them.
I had rescheduling inside each handler.
But the first task was never submitted.
So nothing ever ran.
Registration is not scheduling
This is the shape that matters with BGTaskScheduler:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
registerBackgroundTasks()
submitBackgroundTasks()
return true
}
That second line is the one I was missing.
registerBackgroundTasks() tells iOS which identifiers this process can handle. It does not put work on the queue. Each handler can reschedule itself after it runs, but something has to submit the first request or the chain never starts.
The final version is deliberately boring:
private func submitBackgroundTasks() {
scheduleHealthRefresh()
scheduleMLRetrain()
scheduleEngagementDaily()
scheduleWeeklyInsight()
}
And the retrain request is just a normal BGProcessingTaskRequest:
func scheduleMLRetrain() {
let request = BGProcessingTaskRequest(
identifier: AppConstants.mlRetrainTaskID
)
request.earliestBeginDate = Date(
timeIntervalSinceNow: 86400 * Double(AppConstants.retrainingIntervalDays)
)
request.requiresExternalPower = true
request.requiresNetworkConnectivity = false
try? BGTaskScheduler.shared.submit(request)
}
That is the whole fix. Not a new scheduler abstraction. Not a custom job runner. Just submit the work at launch and let iOS deduplicate by identifier.
The other trap: background queues and SwiftData
The second issue was actor isolation. BGTaskScheduler invokes handlers on a background queue. My persistence layer uses container.mainContext, so every handler that touches SwiftData needs to hop back to the main actor.
private func handleMLRetrain(task: BGProcessingTask) {
task.expirationHandler = { task.setTaskCompleted(success: false) }
Task { @MainActor in
let context = PersistenceController.shared.container.mainContext
let descriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.date)]
)
let logs = (try? context.fetch(descriptor)) ?? []
try? await MLModelManager.shared.trainPersonalisedModel(from: logs)
scheduleMLRetrain()
task.setTaskCompleted(success: true)
}
}
Again, boring. But this is exactly where AI features usually break: not in the model, but in the lifecycle around the model.
One shared path for HealthKit updates
I also pulled the HealthKit merge into one shared function so the scheduled refresh and HealthKit background delivery cannot drift apart:
@MainActor
static func fillTodaysLogFromHealthKit() async {
let metrics = await HealthKitManager.shared.fetchDailyMetrics(for: Date())
let context = PersistenceController.shared.container.mainContext
let log = DailyLog.findOrCreate(for: Date(), in: context)
if log.hrv == nil { log.hrv = metrics.hrv }
if log.restingHeartRate == nil { log.restingHeartRate = metrics.restingHeartRate }
if log.stepCount == nil { log.stepCount = metrics.stepCount }
if log.sleepDuration == nil { log.sleepDuration = metrics.sleepDuration }
try? context.save()
}
The important detail is that manual user-entered values win. Background sync should fill gaps, not overwrite what someone typed by hand.
The tests that caught the boring failures
A few regression tests now pin the things that looked too small to test before:
- one
DailyLogrow per calendar day, because duplicate daily rows poison the training set - future-dated rows must not be mistaken for today
- CloudKit-backed SwiftData schema must load, because a bad relationship can crash the app on launch
- engagement notifications default to enabled, because
UserDefaults.bool(forKey:)returnsfalsefor an unset key - pending Watch entries drain by payload type instead of clearing the whole queue
That last one matters because the Watch queue contains two shapes in one array: symptom logs and wellbeing check-ins. The old reconciliation path could drain symptoms and silently delete check-ins that had not been processed yet.
Silent data loss in a health-adjacent logging app is not a UX bug. It is a trust bug.
The takeaway
On-device AI features are mostly normal mobile engineering with a model in the middle.
The model can be fine while the product is still broken because:
- the background task was registered but never scheduled
- the handler touched persistence from the wrong execution context
- sync overwrote user-entered data
- duplicate daily rows polluted the training input
- a queue drain deleted the wrong payload type
None of that is glamorous. All of it matters.
If a model retrains locally but the scheduler never wakes up, you did not ship personalization. You shipped a nice code path nobody calls.
That is the bar I keep coming back to with practical AI work: not "does the model exist?" but "does the system around it actually keep running after the app leaves the foreground?"
Top comments (0)