Live Activity Timeline

I am working on a live activity showing realtime NYC subway arrival countdowns at a station. As trains arrive I want to remove them from the live activity so that only inbound trains are displayed.

As I understand it this will require a broadcast push notification every time a train arrives, but this is incredibly inefficient (and will likely be throttled) as trains can arrive many times per minute at a busy station. MTA data includes approximately 90 minutes of train arrivals. Is there no way to create a live activity timeline the way widgets can update at planned intervals?

The only viable option I've found is keeping the entire app alive in the background to update the live activity when required by registering for background location updates, but this seems like an unfriendly solution.

Answered by DTS Engineer in 909284022

@picnicbob Thanks for the interesting question and @7_strikes thanks for that great answer. Really good job!

There are a few things I would like to add for Live Activities that is important in my opinion.

An app must be in the foreground to start a Live Activity. Once running, they can be updated in three ways. Locally from the App. If your app is active in the foreground.

ActivityKit Push Notifications: Specially formatted APNs payloads sent from your server. Unlike standard notifications, these don't alert the user.

Time-based UI. Using Text(timerInterval:) or TimelineView to let the UI update itself without new data.

They can be ended by the user swiping them away, by the app explicitly ending them, or by the system when they time out as they have a limit of time.

Hope this helps a little about Live Activities.

Thanks for this great post.

Albert  WWDR

This is doable, but not through the API alone. Live Activities have no timeline, keeping the app alive to implement a "timeline" is not a good solution as you also realize, and one push per arrival won't scale.

The way through is to stop treating the push as the display and treat it as a data feed.

The numbers. From what I can recall based on the MTA Developer site, the system has about 470 stations and roughly 500 trains in service at peak. But you will surely have more accurate numbers.

A train reaches a new station every couple of minutes, so system-wide that's on the order of 15,000+ arrivals an hour. One push per arrival is a non-starter. But the shortest run between two stations is about 90 seconds (you probably have more accurate info, correct?). So if you send a snapshot every 90 seconds, no train can pass more than one station between updates, and the device never loses track of where a train is. Each train's state also packs into about 3 bytes, so the whole system fits in about 1.5 KB of raw data, or about 2 KB after encoding. That's well inside the payload limit.

The view does the work, not the push. Each update is rendered by your own SwiftUI code in the widget extension, so the view can decode a compact payload and compute arrivals for the user's station. Timer-based text and progress views count down on their own between updates, and the view can drop trains whose arrival time had already passed when it rendered. A train that arrives between pushes simply sits at "arriving" until the next update. One risk to consider would be when the system decides to re-render the Live Activity. whether rendering happens as each push arrives, or later when the Lock Screen or Dynamic Island is shown will be up to the device state.

Push a snapshot of the whole system on a fixed cadence. Use one broadcast channel and send a full system snapshot every 90 seconds or so, regardless of how many trains arrived. Because each push is a complete snapshot rather than a change list, a push lost in a dead zone only costs staleness, and the next one corrects everything. A train that has just arrived sits at "arriving" until the next push, which is accurate enough to read as "at the platform."

Encode trains, not stations. Encoding arrivals per station repeats every train at every downstream stop and won't fit the payload limit. Instead, encode each train's position, and let the device work out arrivals at its own station. Each train becomes a fixed 3-byte (24-bit) record: which stopping pattern it is running (9 bits, which also implies route and direction), the index of the next stop on that pattern (7 bits), and how long until it reaches that stop, in 5-second units (8 bits, up to about 21 minutes). Size these field widths from your actual data of course, not from my guesses. Concatenate all the records and base64-encode the result into a single string field in the content state, alongside a snapshot timestamp and a version number for the schedule data:

"content-state": { "v": 42, "t": 1760375575, "d": "AIEXAgc5…" }

Three bytes map to exactly four base64 characters, so 500 trains become a 2,000-character string. Base64 characters need no escaping in JSON. A JSON array of numbers holding the same data would be roughly three times larger.

On the device, the view decodes the string back into records. For each train on a pattern that serves the user's platform, it computes the arrival as: snapshot time + time to the train's next stop + scheduled run time from that stop to the user's station. Those run times would come from a table bundled with the app. If the version number in the payload doesn't match the bundled table, the view should say so rather than show wrong times.

The hard part is the bundled schedule data. That data has to stay in sync with the server, including weekend and overnight service changes. Version it, and decide what the view shows when it meets a train pattern it doesn't recognize. Those kinds of updates can probably be left for when the app is launched by the user to start the activity anyways.

Most of the real effort is in those last details: the record layout, the schedule data pipeline, and handling service changes. As they say "Some engineering might be required".

Happy to compare notes on any of it if useful.


S.Beck -- 7strikes.dev

a push lost in a dead zone only costs staleness, and the next one corrects everything. A train that has just arrived sits at "arriving" until the next push

Unfortunately this is exactly the problem. The subway is full of dead zones and a user headed to a platform without service must not see stale trains in a perpetual "Arriving" state. As you pointed out, I will have the data locally to know when a train should have left, I just can't trigger a SwiftUI update to re-run without the app being alive in the background.

Accepted Answer

@picnicbob Thanks for the interesting question and @7_strikes thanks for that great answer. Really good job!

There are a few things I would like to add for Live Activities that is important in my opinion.

An app must be in the foreground to start a Live Activity. Once running, they can be updated in three ways. Locally from the App. If your app is active in the foreground.

ActivityKit Push Notifications: Specially formatted APNs payloads sent from your server. Unlike standard notifications, these don't alert the user.

Time-based UI. Using Text(timerInterval:) or TimelineView to let the UI update itself without new data.

They can be ended by the user swiping them away, by the app explicitly ending them, or by the system when they time out as they have a limit of time.

Hope this helps a little about Live Activities.

Thanks for this great post.

Albert  WWDR

Thank you, @DTS Engineer, but in my testing a Live Activity did not support SwiftUI's TimelineView. I will try again with the iOS 27.2 beta and report back.

@DTS Engineer TimelineView does seem to be working. Maybe I had it configured wrong when I tried earlier or maybe I wasn't waiting long enough for an update - it seems like a cadence of about 30 seconds is the maximum refresh frequency allowed in a Live Activity. Thank you for pointing me in the right direction!

Thanks for the reply, so you tried https://developer.apple.com/documentation/swiftui/timelineview and does not work? Any chance for me to take a look? Can you provide a focused sample for me to review and see the issue?

If you're not familiar with preparing a test project, take a look at Creating a test project.

Albert  WWDR

@DTS Engineer Ugh, my mistake - it's not working. It was my app pushing updates to the Live Activity in the background while in the debugger. Once I detach the debugger and the main app suspends the updates stop. TimelineView is not working. I'll try to put together a quick demo project.

@DTS Engineer Here you go: [GitHub Demo]

This project includes a periodic 10-second TimelineView in a Live Activity. The Live Activity's UI is never updated.

Apologies for interjecting here, but I'm afraid you two are talking past each other and I can't bear to watch the train going down the wrong track 🫨

The situation is that TimelineView doesn't work in Live Activities. Either it's a bug ( @DTS Engineer can help with that), or it was never documented to work in a Live Activity.

I couldn't find anything from Apple saying either way. Live Activities show a stored snapshot of the view and only re-render on an ActivityKit content update. The sample here never sends one, so the .periodic schedule is never followed.

Others have hit the same thing, noting that it happened to work in the debugger just like you observed yourself: https://developer.apple.com/forums/thread/766932 (FB15590204, no fix in the thread).

What to use instead Use time views the system draws itself. These keep ticking without any updates:

  • Text(timerInterval:) and ProgressView(timerInterval:)
  • Text(date, style: .timer) or .relative
  • Text(.currentDate, format: ...) (iOS 18+)

One limit: the system picks the tick rate, so you can't get a custom interval like every 10 s.

What I did to make it work (5-minute countdown)

// Attributes: the target time lives in the activity data, not the view
var start: Date
var target: Date

// Live Activity view (clamped: start > end in a ClosedRange crashes)
Text(timerInterval: min(.now, target)...target)
    .monospacedDigit()
ProgressView(timerInterval: min(start, target)...target, countsDown: true)

// App: start the activity
let start = Date.now
let target = start.addingTimeInterval(5 * 60)
Activity<WidgetExtensionAttributes>.request(
    attributes: .init(name: "Live Activity", start: start, target: target),
    content: .init(state: .init(emoji: "😀"), staleDate: target))

Setting staleDate: target lets the view check context.isStale and show "Time's up" after the countdown ends.


S.Beck -- 7strikes.dev

If you believe there is a bug on that API for Live Activities, I would recommend to file a bug so can be reviewed.

Once you open the bug report, please post the FB number here for my reference.

If you have any questions about filing a bug report, take a look at Bug Reporting: How and Why?

Thanks

Albert  WWDR

@7_strikes The updates are not limited to Text timers, and those that are should not display seconds as the train arrivals aren't precise enough to warrant sub-minute accuracy. An "Arriving" train must be removed once it has departed (or has been scheduled to depart). Without that guarantee Live Activities are useless in this otherwise very useful situation.

@DTS Engineer As @7_strikes notes it is not documented anywhere that TimelineView is supported in Live Activities, and as it's not supported in Widgets I didn't really expect it to be. I have submitted FB25214587 requesting "Support for TimelineView in Live Activities."

@picnicbob if I may put my user hat on for a change: I know when I am 4 levels deep in a station and things don't work. So, if the Live Activity shows "No connection. Updates Paused" I am sure the users will understand.

If you think the solution I proposed works in general except for the conditions where pushes are lost in dead zones, you can change the LA to show a N/A message using staleDate.

staleDate will work for this because the check happens on the device. When there is a periodic push to update the snapshot of the system, then no new update arrives, the staleDate passes on its own, with or without a connection.

How to set it up

  1. Give every update a deadline. Each push sets a stale-date a little past when the next update is due. For example, if your server pushes every 90 seconds, set it to now + 2 minutes. Every push that arrives on time moves the deadline forward. If pushes stop arriving, the deadline passes. In the push payload:

    "aps": {
      "timestamp": 1760630000,
      "event": "update",
      "content-state": {_your content_, "updatedAt": 1760630000 },
      "stale-date": 1760630120
    }
    

    For updates sent from the app, pass staleDate: to ActivityContent the same way.

  2. Carry the update time in the content state, e.g. var updatedAt: Date, so the view always knows how old its data is.

  3. Have the view show both states:

    if context.isStale {
        Label("Updates paused", systemImage: "wifi.slash")
        Text("Last update \(Text(context.state.updatedAt, style: .relative)) ago")
    } else {
        // normal live content
    }
    

    Text(..., style: .relative) is one of the system-drawn views, so "3 min ago" keeps counting even with no connection. You could also show the "last update" line all the time, which gives people a hint before the stale state kicks in.

Caveats

  • Stale doesn't mean offline. It only means "no update arrived by the deadline." That could be no connection, but also your server failing, APNs throttling your pushes, or Low Power Mode delaying delivery. Wording like "Updates paused" or "Updates delayed" is accurate. "No connection" might not be.
  • The deadline has to account for expected push delivery delays. If you set it too tight, normal delivery delays will make the activity flicker into the stale state. A margin reasonably greater than your normal push interval is a good starting point.
  • I can't confirm how soon the screen changes. Apple's docs say that when the stale date arrives, the activity's state changes to .stale, and the view gets context.isStale. They don't say how soon after that the Lock Screen redraws. Test it on a device in airplane mode.
  • Recovery is automatic. When the connection comes back, APNs delivers the latest push. That push carries a new stale-date, so the activity returns to normal.
  • The app can't reliably catch this itself. You could watch for connection loss in the app with NWPathMonitor and call activity.update, but that only works while the app is running, and if it were, you wouldn't have any of these problems. The stale date is the approach that also works while the app is suspended.

Test this in your demo: replace staleDate: target with a rolling deadline and you'd get both behaviors.


S.Beck -- 7strikes.dev


Sources:

Live Activity Timeline
 
 
Q