Thirty seconds is not a sync budget
A widget button that kicks off "sync now" looks like a one-liner until the device is on a flaky network and the work blows past thirty seconds. App Intents are not a free background daemon. The system gives you a short window, then expects you either to finish or to opt into LongRunningIntent — and that protocol only extends the window if you keep proving the work is still moving.
The default window is shorter than you think
When an intent runs from Siri, Shortcuts, a widget, or another system surface, it has roughly thirty seconds to finish on iOS. That is enough for a toggle, a create, or a single API call that returns quickly. It is not enough for a sync that walks a local change queue, an export that zips media, or an on-device model pass over a large note set.
The failure mode is quiet from the user's point of view. The intent starts, the spinner goes away, and the work is half done — or the system kills it mid-flight and the next launch has to figure out what was already written. If your "sync" path is only tested while the app is foregrounded, you have not tested the path people will actually hit from a widget.
We treat any intent that touches the network, files, or a model as guilty until proven under thirty seconds on a bad connection. Happy-path timing in Xcode is not the budget that matters.
LongRunningIntent is the opt-in, not a free extension
LongRunningIntent is Apple's answer for work that outlives the short window: file operations, data synchronization, machine learning inference, large processing jobs. You adopt the protocol, wrap the real work in performBackgroundTask, and let the system manage the background lifecycle. Progress shows up as a Live Activity so the person can see the job is still running and stop it.
The protocol refines ProgressReportingIntent on purpose. Extended runtime is conditional. You update the inherited progress property as units complete. Stop updating and the system can revoke the extension and end the task early. Progress is not a UI nicety here — it is the heartbeat that keeps the process alive.
Pair it with CancellableIntent. People cancel from the Live Activity. The system cancels on timeout or resource pressure. onCancel is where you flush partial state, mark the job resumable, and refuse to leave a half-applied remote write sitting as if it finished.
Do not share a code path that assumes the app is open
The common shortcut is to call the same sync function the in-app button calls. That function usually assumes you can show alerts, write to the active scene, or leave in-memory caches half updated because the user is still looking at the screen. An intent that continues after the app is backgrounded does not get those assumptions.
On Endeo-shaped work — local SwiftData as the real store, optional sync later — the intent should enqueue or drain durable work units, not drive UI. Each unit should be idempotent on a stable ID. If the Live Activity is dismissed mid-sync, the next run should continue from the last committed unit, not replay the whole queue and duplicate remote writes.
That is the same discipline as webhook ingestion: assume the run will be interrupted. Design the store so interruption is boring. The intent is just another entry point into that queue.
When to adopt it, and when to refuse the surface
Adopt LongRunningIntent when the product promise is "start this from outside the app and let it finish": sync now, export a session, process a batch, upload a set of captures. Those jobs already have progress semantics people understand, and a Live Activity matches the expectation.
Refuse the surface when the work needs interactive decisions mid-flight, or when you cannot make the job resumable. A thirty-second intent that opens the app to a specific screen is often honest. A long-running intent that cannot recover from cancel is worse than no shortcut at all — it teaches people the button is unreliable.
Also be clear about GPU-backed work. LongRunningIntent can request background GPU access on supported devices for photo processing or on-device inference, but that is an entitlement and a power conversation, not a default. Put it on the jobs that need it; leave ordinary sync on the CPU path.
How we verify the intent path
Run the intent from a widget or Shortcut with the app dismissed, not from a debug button inside the running app. Throttle the network. Confirm the Live Activity appears, progress moves, and cancel leaves the store consistent. Kill the app mid-run and launch again — the queue should resume without inventing duplicates.
Log progress updates with timestamps. If updates cluster at the start and then go silent while work continues, you have the exact pattern the system is allowed to cancel. Treat a stalled progress value as a bug even when the task eventually finishes in the foreground.
Ship the short path for tiny jobs. Ship LongRunningIntent for the jobs that already blow the budget on a real device. Do not market a widget sync until both paths have been through that checklist.
Questions
- When should an App Intent use LongRunningIntent?
- When the work can exceed roughly thirty seconds in the background — sync, export, large file ops, on-device inference. Everyday toggles and quick creates stay on a normal intent. If happy-path timing only works in the foreground, assume you need the long-running path.
- Why does progress reporting matter for long-running intents?
- LongRunningIntent builds on ProgressReportingIntent. The system extends background runtime while progress keeps moving. If you stop updating progress, it can cancel the extension early. Progress is the heartbeat, not decoration.
- What should happen if the user cancels from the Live Activity?
- Adopt CancellableIntent and use onCancel to flush partial state, mark the job resumable, and avoid leaving half-applied remote writes. The next run should continue from the last committed unit.