ActivityKit assumes you know when you’ll finish. I never do.
What happens when ActivityKit’s “you know when you’ll finish” assumption meets a pipeline where the ETA moves
A user taps Generate on Noema. The track is taking longer than usual, so they back out and tap Generate again.
A new generation starts. The lock screen still shows the old one, and it says “Finalizing…” at 95% progress, for a track that’s 2 seconds old.
ActivityKit is doing exactly what it’s built to do here, applied to data it was never designed for.
The assumption baked into the API
A Live Activity has one Activity<Attributes> handle, one immutable startDate, and a content state you push updates into. Apple's model, and most of the WWDC sample code, assumes you know your total duration up front:
A ride is 12 minutes away, a workout is 45 minutes, and a delivery has a window. Progress iselapsed / total, and total doesn't move.
Noema generates a track from ambient signals:
location, weather, time, motion.
There’s no fixed duration. The backend gives me a rolling ETA that shifts as the model works, sometimes up, sometimes down.
estimatedCompletionDate on my content state is a live number, not a constant:
public struct ContentState: Codable, Hashable {
var status: String
var displayStatus: String?
var progress: Double
var estimatedCompletionDate: Double? // unix timestamp, re-sent on every update
var trackTitle: String?
var queuePosition: Int?
}That one field, estimatedCompletionDate as a moving target instead of a fixed one, is where every other decision in this post comes from.
The gen-1 to gen-2 race
Here’s the bug from the top of this post. It happens because end() for the first activity is async, dispatched from aTask, and the user can tap Generate again before it actually runs. Or the app got backgrounded mid-run, and the completion event never arrived to trigger end() at all.
So a stale activity from generation 1 is still on the lock screen when generation 2 starts. Its startDate is from 2 minutes ago. The widget computes(now - startDate) / totalDuration, which is already near 1.0, and the label logic reads that as "basically done."
The user sees "Finalizing..." on what they think is a brand new generation, because visually it's one progress widget and they don't distinguish activity instances.
The fix is defensive cleanup on every start():
Task { @MainActor [weak self] in
guard let self else { return }
await self.endStaleActivities(except: conversionId)
}
progressPolicy.clear(conversionId: conversionId)Every activity whose conversionId doesn't match the new one gets ended immediately, and its monotonic progress state gets wiped so it can't leak into the next run. I also clear that state defensively in start() itself, not just in end(), because I'd already been burned once by a path where end() never fired.
Making elapsed time a floor
Once the stale-activity race was fixed, a second problem showed up:
Get Oniel Rosario’s stories in your inbox
Join Medium for free to get updates from this writer.
The ring would stall. The app backgrounds mid-generation, the SSE connection to the backend drops, and when the app comes back the last progress value it has is stuck at 0.3 while the reconnect takes a few seconds. The user watches a frozen progress bar on something that’s clearly still running.
The fix was to stop treating elapsed time as a fallback for missing data and start treating it as an unconditional floor:
let timeBased = max(0.0, min(0.95, elapsed / totalDuration))
let rawProgress = backendProgress ?? 0.0
var resolvedProgress = max(rawProgress, floorProgress, timeBased)Time based progress can never hit 1.0 on its own (only the backend gets to declare done), but it can drag the ring forward the moment the app is back in front of the user, even before the socket has reconnected. Same resolver feeds the in-app banner and the widget, so both surfaces agree.
Status labels get the same monotonic treatment, just by rank instead of by number. I added a guard so “Finalizing” can’t show below 70% progress, because an early backend build would occasionally send that label at around 10%. Nothing was wrong on the backend side; it’s just an eager label from a different stage of the pipeline.
Without the guard, the lock screen would flash “Finalizing…” then drop back to “Generating Track…” a few seconds later, which reads as broken even when the underlying data is fine.
What App Review actually caught
The other regression I didn’t catch myself:
App Review flagged, on iPad, a generation that sat on “Starting…” indefinitely.
What was happening:
Any backend label that wasn’t a “base” label (queued, pending, preparing) got trusted verbatim, so once the backend said “Starting” once, that string stuck on screen even as time based progress climbed toward 90%.
The fix takes the max of the backend’s label rank and the progress-derived label rank, whichever ranks higher wins, instead of just trusting whichever update arrived last.
I’d rather a slightly-behind label than a stuck one. A label that’s a beat late is a rounding error. A label that’s frozen for 90 seconds is a bug report.
The one place I gave up on real-time entirely
Queue position gets none of this treatment. When a generation is waiting behind others, the widget bypasses the whole resolver and shows “You’re #3 in line” with an indeterminate progress bar, and nothing else. The backend can estimate a queue wait (ceil(position / 5) * 60s), but I chose not to show it.
A number like that goes stale within seconds of being computed, and a wrong ETA reads as worse than no ETA at all. Sometimes the honest answer to "can you compute this?" is "you can, but don't."
Where the pipeline does give me a real fixed endpoint, I hand it straight to the system’s own ProgressView(timerInterval:) instead of animating it myself. That's the only place in this whole feature where I trust the OS's original assumption completely, because for once it's true.
Live Activities want a straight line from start to finish. My pipeline gives me a line that bends. Most of the work here wasn’t building the feature, it was deciding, field by field, which parts of the data could lie to the user a little (in their favor) and which parts had to stay honest even when honest looked worse.
Noema is an AI-powered music generation app for iOS. The lock screen is the one screen you can’t design your way around a moving target on, so it ended up being where the app’s honesty about uncertainty shows the most.









