DEV Community

Cover image for Why your iOS Live Activity silently stops updating
Pasha Dev
Pasha Dev

Posted on

Why your iOS Live Activity silently stops updating

Your Live Activity looks perfect for the first minute, then freezes. New activity.update(...) calls return no error, yet the Lock Screen never changes. This is not a bug in your code: iOS meters high-priority Live Activity updates against a per-device budget, and once you burn through it, the system quietly drops the rest.

The budget nobody warns you about

Live Activities shipped in iOS 16.1. The throttling rules landed quietly and live mostly in forum threads, not in the headline docs. Two priority levels decide everything. A push carrying apns-priority: 5 is delivered opportunistically and costs nothing against your budget. A push carrying apns-priority: 10 is delivered right away — and every one of those spends from a budget iOS recalculates per device, based on battery, thermal state, and how many priority-10 updates you already fired.

Send a priority-10 update every second for a word-by-word display and you will drain that budget in well under a minute. After that, updates are delayed or dropped. No exception is thrown. The activity simply stops moving.

Local updates through activity.update(...) are not billed the same way, but the system still coalesces them when they arrive faster than it wants to render. Either path punishes a design that treats the Lock Screen like a 60 fps canvas.

Apple's reasoning is defensible. A Live Activity sits on the Lock Screen and in the Dynamic Island, both surfaces the system itself redraws; unbounded updates there would wreck battery life on hardware that already keeps the display polling. The budget is the rent for that real estate. Fighting it head-on loses every time — the winning move is to need fewer updates.

Render time on the widget, not from the app

Here is the shift that fixes most of these cases: stop sending the current value, and send the window instead. SwiftUI can advance a countdown, a progress bar, and a clock entirely on-device, with zero updates after the activity starts.

Model your state around a ClosedRange<Date>:

import ActivityKit

struct SessionAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var window: ClosedRange<Date>   // start ... end
        var title: String
    }
    var sessionID: String
}
Enter fullscreen mode Exit fullscreen mode

Bind the widget to that range. Text(timerInterval:pauseTime:countsDown:showsHours:) and ProgressView(timerInterval:countsDown:) both drive themselves off the system clock:

import WidgetKit
import SwiftUI

struct SessionLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: SessionAttributes.self) { context in
            VStack(alignment: .leading, spacing: 6) {
                Text(context.state.title).font(.headline)

                Text(timerInterval: context.state.window, countsDown: true)
                    .font(.system(.title, design: .rounded))
                    .monospacedDigit()

                ProgressView(timerInterval: context.state.window, countsDown: false)
                    .tint(.pink)
            }
            .padding()
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.center) {
                    Text(timerInterval: context.state.window, countsDown: true)
                        .monospacedDigit()
                }
            } compactLeading: {
                Image(systemName: "music.note")
            } compactTrailing: {
                Text(timerInterval: context.state.window, countsDown: true)
                    .monospacedDigit()
                    .frame(maxWidth: 44)
            } minimal: {
                Image(systemName: "music.note")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Start it once, hand over a 180-second window, and walk away:

let now = Date()
let state = SessionAttributes.ContentState(
    window: now ... now.addingTimeInterval(180),
    title: "Focus block"
)
let activity = try Activity.request(
    attributes: SessionAttributes(sessionID: "s-42"),
    content: .init(state: state, staleDate: nil),
    pushType: nil
)
Enter fullscreen mode Exit fullscreen mode

The number on the Lock Screen now ticks down three minutes without a single follow-up call. Your budget stays untouched.

Step 1 — flip the two Info.plist keys

Open your app target, not the widget extension. Go to the Info tab, or edit Info.plist as source. Add NSSupportsLiveActivities as a Boolean set to YES; without it the activity never appears at all. If you genuinely need frequent priority-10 pushes on top of the time-driven UI, add NSSupportsLiveActivitiesFrequentUpdates as a Boolean YES as well (iOS 16.2 and later). That second key raises the budget — it does not remove it, and the user can switch it off under Settings.

Step 2 — reserve priority 10 for state changes, not ticks

A countdown ticking is not news; a countdown pausing is. Push apns-priority: 5 for anything cosmetic and keep 10 for the handful of moments that must land immediately — pause, resume, finish. A typical session should fire 2 or 3 priority-10 updates total, not 200.

Step 3 — pause without repainting every second

When the user pauses, you do need one real update, because a self-running timer has no way to know it should stop. Freeze it by collapsing the window to the current instant and carrying the remaining time in a separate field:

func pause(_ activity: Activity<SessionAttributes>, remaining: TimeInterval) async {
    var state = activity.content.state
    state.window = Date() ... Date()   // renders a static 00:00 delta
    await activity.update(.init(state: state, staleDate: nil))
}
Enter fullscreen mode Exit fullscreen mode

That is one priority update for a pause that might last ten minutes — instead of 600 updates spent holding a number still.

Step 4 — end the activity, and set a staleDate

A timer that runs to zero and keeps showing 00:00 reads as broken. Two mechanisms prevent that. Set staleDate so the system marks the content stale at a known moment and the widget can switch to a finished layout via context.isStale. Then call end(...) once the session is genuinely over:

await activity.end(
    .init(state: activity.content.state, staleDate: nil),
    dismissalPolicy: .after(.now + 15)   // linger 15s, then clear
)
Enter fullscreen mode Exit fullscreen mode

.immediate clears it at once; .after(_:) leaves it on the Lock Screen for a grace period; .default lets iOS decide, usually within four hours. Choose .after for anything the user might glance at twice.

Batch discrete changes into one update

When several things change close together — a new title, a new subtitle, a new accent color — coalesce them into a single update instead of three. Diff your state first, and skip the call entirely when nothing meaningful changed:

guard newState != activity.content.state else { return }
await activity.update(.init(state: newState, staleDate: staleDate))
Enter fullscreen mode Exit fullscreen mode

Fewer, fatter updates beat many thin ones under a metered budget every time.

Watch the throttle happen

You cannot fix what you cannot see. Open Console.app, plug in the device, and filter on the process name of your widget extension. Fire ten priority-10 updates in a tight loop; the log shows the first few applied and the rest annotated as budget-limited. Compare that against the time-driven build, which logs one request at start and nothing after. Thirty seconds in Console teaches more than any forum thread about where your updates went.

The honest limitation

Time-driven rendering only covers content that changes on a predictable clock. The moment your Live Activity must show something the widget cannot compute — a fresh line of text, an incoming score, a driver's changing position — you are back to real updates, and back under the budget. No API lets a widget pull data on its own; Apple blocks network calls and repeating timers inside the extension by design.

The free alternative for genuinely unpredictable content is push updates from your own server with apns-priority: 5, letting APNs deliver them opportunistically instead of hammering priority 10. Slower and less precise, but it costs nothing beyond a push key, and it will not get throttled into silence. Paid relays such as OneSignal or Braze wrap the same APNs mechanism — convenient, yet they cannot beat the budget either, because the limit lives on the device, not on their servers.

Copy-paste checklist

  • NSSupportsLiveActivities = YES in the app target's Info.plist
  • State modelled as a ClosedRange<Date>, not a per-second value
  • Text(timerInterval:) / ProgressView(timerInterval:) doing the animation
  • Priority 10 reserved for pause, resume, and end — 2 to 3 per session
  • staleDate set, so a stalled activity dims instead of lying
  • NSSupportsLiveActivitiesFrequentUpdates added only after you measured a real need

FAQ

Why do my activity.update calls succeed but nothing changes on screen?
A call returning without throwing means it was accepted, not that it rendered. Rapid updates get coalesced, and rapid priority-10 pushes get dropped once the budget is spent. Space them out, or move the animation into time-driven views.

Does the timer keep running when the phone is locked or the app is killed?
Yes. Text(timerInterval:) is evaluated by the widget process against the system clock, so it advances whether or not your app is alive.

Can I show hours, minutes, and seconds together?
Yes. The initializer takes showsHours, which defaults to true and adds an hours field automatically once more than 60 minutes remain.

How many high-priority updates do I actually get?
There is no published fixed number — iOS computes it live from battery and thermal conditions. Treat it as small, and design as though you have roughly a dozen priority-10 sends per hour before throttling starts.

What is the minimum iOS version for all this?
Live Activities need iOS 16.1; the frequent-updates key needs 16.2. Dynamic Island rendering appears only on iPhone 14 Pro and later, but the Lock Screen activity works on every device running 16.1.

Further reading

If you are building this for CarPlay specifically, the surface behaves a little differently from the Lock Screen — I wrote a practical walkthrough of Live Activities for CarPlay lyrics covering what renders there and what silently does not.

Top comments (0)