An app extension is a separate target with its own bundle id and its own process, launched by the system when a host such as the Home Screen or the share sheet needs it. It shares your source code but none of the app's running state.
Common kinds
Widget extension: WidgetKit widgets, Live Activities and (iOS 18+) Control Centre controls.
Share and Action: receive NSExtensionItems in the share sheet.
Notification Service and Content: rewrite a push before it is shown, or draw a custom notification UI.
App Intents: run Siri, Shortcuts and Spotlight actions without launching the app.
How it differs from the app
Process: its own process and sandbox. Singletons configured in App.init or AppDelegate do not exist there; data crosses only through an App Group container, a shared Keychain access group or the network.
Lifecycle: no UIApplication (UIApplication.shared is unavailable) and no launch callbacks. The system starts it on demand and ends it as soon as the job is done.
Memory and time: far tighter than the app: a widget extension is limited to tens of MB (about 30 MB is commonly measured; Apple does not document it) and a few seconds per callback. Going over gets the process killed, not slowed.
iOSMiddle
App Extensions & WidgetKit
WidgetKit timelines, Live Activities, App Intents, share and notification extensions, App Groups
Questions closed
0 / 20
- 0Review
Questions
A widget is a Widget with a configuration, a timeline provider that produces dated entries, and a SwiftUI view that renders each entry. WidgetKit renders those views ahead of time and displays the stored result, so nothing in the view runs while it is visible.
**Widget**: declares the kind string (the id you reload by), the configuration and supportedFamilies.
Configuration: StaticConfiguration, or AppIntentConfiguration when the user can edit the widget.
**TimelineEntry**: a date at which the entry becomes current, plus the data the view needs.
**TimelineProvider: returns entries and a reload policy. It is the code that fetches data, inside the extension, when the system asks.
WidgetBundle** marked @main: the entry point when the extension ships several widgets.
The rendered view is archived, so onAppear, timers and gestures do nothing on screen; anything live comes from views the system animates, such as Text(_:style:).
placeholder returns an instant, redacted skeleton; snapshot returns one entry for the widget gallery and other transient views; timeline returns the dated entries the widget will actually show, plus when to ask again.
**placeholder(in:): synchronous, no I/O. WidgetKit draws it redacted while the real view is being prepared, so hard-code sample values.
snapshot(for:in:)**: called for the widget gallery and the first appearance after the user places the widget. When context.isPreview is true, return sample data immediately: waiting on a store or the network here is why a widget sits blank in the gallery or shows its signed-out state.
**timeline(for:in:)**: the only callback that should read a store or the network, and even it has only a few seconds.
context.family and context.displaySize tell you which size is being rendered; one provider serves every family.
Configurable widgets use AppIntentTimelineProvider (iOS 17+), whose async methods receive the configuration intent; TimelineProvider keeps the completion-handler shape for static widgets.
.widgetURL(_:) makes the whole widget one tap target with one URL; Link gives separate destinations to parts of the widget, but only in the medium, large and extra-large families; small widgets and Lock Screen accessories get just widgetURL.
Apply one widgetURL, at the root. Several in the same tree give undefined behaviour, and a row view carrying its own is a common reason every row opens the same screen.
Use both together: a Link per row, and widgetURL as the fallback for taps that hit no link.
The app receives the URL in onOpenURL. Encode a stable identifier, never a list index: the entry that drew the row may be hours old.
Button(intent:) is not a link: it runs an App Intent in the background instead of opening the app.
A Live Activity is a card for one ongoing event (a delivery, a ride, a match) that the app starts and then updates in place on the Lock Screen and in the Dynamic Island. Unlike a widget it is tied to one event with instant, explicit updates; unlike a push it is one card that changes instead of a pile of messages.
vs a widget: a widget is placed by the user and refreshed by the system on a budget. A Live Activity is started by the app, has a clear end and gets updates (activity.update or an ActivityKit push) that appear immediately.
How it is built: ActivityKit for the data and lifecycle, SwiftUI views in the widget extension for the UI.
Limits: active for up to 8 hours, then the system ends it; the card can stay on the Lock Screen up to 4 more hours. Users can turn it off per app.
It never ticks: WidgetKit renders the body once and displays the archived result, so neither onAppear nor the timer can change @State on screen, and the number never changes. A countdown in a widget must be a view the system animates itself, such as Text(timerInterval:countsDown:).
While the widget is visible the extension is not running: there is no run loop for Timer.publish to fire on and no re-render after a state change.
Text(timerInterval:countsDown:) and Text(_:style: .timer) are drawn and updated by the system with zero reloads; ProgressView(timerInterval:countsDown:) does the same for a bar.
Text(date, style: .timer) counts up again once the date has passed, so add a timeline entry dated at the deadline that switches the view to a finished state.
A ClosedRange whose end is before its start traps at runtime; check the deadline before building start...end.
A reload policy tells WidgetKit the earliest time it may ask the provider for a new timeline: a request, not a schedule. To refresh at once, the app writes the new data where the widget reads it and calls WidgetCenter.shared.reloadTimelines(ofKind:).
**.atEnd: ask again after the last entry's date. A single entry dated now means "ask again immediately", which burns the daily budget.
.after(date): ask no earlier than that date. Use it when you know when the data can change: midnight, market open, the next shift.
.never**: the system never asks on its own; only the app or an App Intent can trigger a reload.
reloadTimelines(ofKind:) and reloadAllTimelines() work only while the app or one of its extensions is running. An app that has not run since yesterday reloads nothing, which is the usual reason for a stale widget. Wake it with a background push or a BGAppRefreshTask.
Save first, reload second: the provider reads the App Group store, so a reload before the write finishes renders the old value.
WidgetCenter.shared.currentConfigurations() tells you which widgets are placed, so you can skip syncing work when none are.
UserDefaults.standard is private to each process's sandbox, so the app saves into its own container and the widget extension reads its own, empty one; integer(forKey:) returns 0 for the missing key. Both targets must use the App Group suite.
Why: the app and the extension are separate bundles with separate containers; .standard is a plist under Library/Preferences in the container of whichever process calls it. Sharing the source file shares the code, not the data. The reload works fine: it just re-reads an empty store.
The fix: enable the App Groups capability with the same group id on both targets and use UserDefaults(suiteName:) with that id. Files and databases follow the same rule: put them under FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:).
Silent failure: if one target lacks the entitlement, the suite there behaves as a private store: the data is not shared and nothing fails. containerURL(forSecurityApplicationGroupIdentifier:) returns nil in that case, so an assert on it at launch in both targets catches a misconfigured target in the first debug run.
Migration: values users already have sit in the old .standard store; copy them into the suite once on the first launch after the update, or the widget starts from zero for existing users.
Every entry is dated .now, so all four become due at the same instant and the widget never steps through the hours. And because the last entry's date has already passed, .atEnd asks for a new timeline right away, again and again.
An entry's date is when WidgetKit swaps it in, not when the data was produced. Date each entry at hour.start.
With correct dates, .atEnd asks again when the last entry becomes current: one reload covers four entries and about three hours before the next one.
A provider that keeps asking immediately spends its daily budget in the first hour; after that the system stops reloading it and the widget looks frozen although the code "refreshes".
An entry dated in the past is fine for the first slot: WidgetKit shows the latest entry whose date has already arrived.
Each placed widget gets a rolling budget, typically 40–70 reloads a day (roughly one every 15 to 60 minutes), sized by how often the user actually looks at it. Once it is spent, the system silently stops honouring your reload policy until the budget refills.
What counts
Reloads WidgetKit performs because your .atEnd or .after policy asked for them.
Reloads you request with WidgetCenter.shared.reloadTimelines(ofKind:) while the app is in the background.
What does not
Reloads while the containing app is in the foreground, or has an active audio or navigation session.
System changes such as the locale or Dynamic Type, and the user editing the widget's configuration.
Anything while debugging from Xcode: the budget is commonly reported as not enforced there, which is why a widget that refreshes every minute in development stalls in production.
Living within it
Return many future entries from one reload instead of polling: predictable data costs a few reloads a day.
For unpredictable changes, send a background push that wakes the app, sync there, then reload; do not shorten the interval.
Widgets the user rarely sees, and devices in Low Power Mode, get fewer reloads.
Use AppIntentConfiguration with a WidgetConfigurationIntent whose @Parameter is an AppEntity; the entity's EntityQuery supplies the choices, and it runs inside the widget extension while the user edits the widget.
AppIntentConfiguration (iOS 17+) replaced IntentConfiguration with its .intentdefinition file and separate Intents extension. The intent is now a plain Swift struct.
EntityQuery.suggestedEntities() fills the picker; entities(for:) resolves identifiers the user picked earlier. For a fixed set of values, an AppEnum or a DynamicOptionsProvider is lighter.
The provider becomes an AppIntentTimelineProvider, and every callback receives the configured intent, so timeline(for:in:) reads configuration.project directly.
The query runs with the extension's memory and time limits, possibly before the app has ever launched. Read a cached list from the App Group; a cold API call leaves the edit sheet spinning.
entities(for:) must cope with an id that no longer exists: return an empty array and let the timeline fall back to a default instead of throwing.
Migrating an iOS 16 widget: Xcode can convert the intent definition to an App Intent. Keep the generated type and parameter names, or widgets already on users' screens lose their configuration.
Only Button(intent:) and Toggle(isOn:intent:) are interactive: a tap runs an AppIntent in the background, the intent updates shared data, and WidgetKit then reloads the widget. Gestures and button closures never run: the view on screen is an archived snapshot.
A tap that hits no intent control falls through to widgetURL and opens the app.
perform() runs outside the view with only a few seconds. Write the result to the App Group store; the next render reads from there, not from any in-memory state.
.invalidatableContent() marks views the tap makes stale; the system shows them in a pending style until the reload, so the tap feels instant.
Toggle(isOn:intent:) shows the new position at once, but the render after perform() is the truth: if the provider reads a store that perform() did not update, the toggle snaps back.
The tapped widget reloads automatically; call WidgetCenter.shared.reloadTimelines(ofKind:) only for other widget kinds the change affects.
If the action needs UI, conform to OpenIntent or set openAppWhenRun; that launches the app, so keep it the exception.
A widget renders in .fullColor, .accented or .vibrant mode, and the last two drop your colours, so anything that carries meaning in hue alone disappears. Read the mode from the environment, pair colour with a glyph or a word, and declare the background with containerBackground.
**.fullColor: the standard Home Screen and StandBy by day.
.vibrant: Lock Screen widgets and StandBy at night. Content is desaturated and mapped by brightness onto the system material, so pale grey on white vanishes.
.accented**: the tinted and clear Home Screen styles and watchOS. Views marked .widgetAccentable() take the accent tint, everything else is desaturated; .widgetAccentedRenderingMode(_:) (iOS 18+) controls how an individual Image is treated.
**.containerBackground(for: .widget)**: required since iOS 17. The system removes this background where it does not belong (StandBy, the iPad Lock Screen, tinted styles); a background drawn with .background stays and looks wrong.
.containerBackgroundRemovable(false) keeps a photo-style background, at the cost of the widget not being offered in those places.
Check every surface on a device before shipping: Home Screen in each style, Lock Screen and StandBy.
ActivityAttributes holds what stays fixed for the activity's whole life, such as the order id and restaurant; its nested ContentState holds what changes, such as the step and ETA. You start with Activity.request, replace the state with update, and finish with end.
Attributes cannot change after request. Fixing a wrong value means ending the activity and starting a new one, which the user sees as the card disappearing and another appearing.
ContentState must be Codable & Hashable. Each update replaces it entirely and is capped at 4 KB, so send ids and short values, and keep images in the extension's bundle or the App Group.
Activity.request(attributes:content:pushType:) works only while the app is in the foreground (a push-to-start push is the exception) and only when ActivityAuthorizationInfo().areActivitiesEnabled. The app's Info.plist needs NSSupportsLiveActivities.
ActivityContent(state:staleDate:relevanceScore:): after staleDate the view sees context.isStale and can say the data may be out of date.
end(_:dismissalPolicy:): .default leaves the final state on the Lock Screen for a while, .immediate removes it, .after(_:) picks the time.
After a relaunch, find running activities in Activity<DeliveryAttributes>.activities instead of starting a duplicate.
Compact (a leading and a trailing view) when yours is the only running activity, minimal when two or more apps have one, expanded when the user long-presses, plus the Lock Screen view, used on devices without an Island and for update banners.
Compact: compactLeading and compactTrailing sit either side of the camera. Only one app at a time gets them.
Minimal: a small circle. With two activities, one is attached to the Island and one detached beside it; with more, the system chooses. It must identify your app on its own: a glyph, not a bare progress ring.
Expanded: built from DynamicIslandExpandedRegion with .leading, .trailing, .center and .bottom.
Lock Screen view: the first closure of ActivityConfiguration, and it is required. Keep it under about 160 points tall or the system truncates it.
.widgetURL handles taps on the compact and minimal views; Link works inside expanded regions. .keylineTint(_:) colours the outline the system draws around the Island content.
Timer text in the compact view needs .monospacedDigit() and a fixed frame(width:), or the Island jitters as digits change.
An AppIntent is a Swift type describing one action (title, @Parameters and perform()) that the system can run without your UI. On its own it only appears in the Shortcuts app; listing it in an AppShortcutsProvider with spoken phrases makes it available in Siri and Spotlight from install.
**AppShortcutsProvider**: up to 10 AppShortcuts per app, registered automatically when the app is installed. Every phrase must contain \(.applicationName), which is how Siri tells your app apart from others.
**parameterSummary: the sentence shown in the Shortcuts editor. Parameters left out of it are hidden behind a disclosure, so a required one looks unset.
Model objects as parameters**: make them an AppEntity with an EntityQuery (EntityStringQuery resolves a spoken name). Call updateAppShortcutParameters() when the list changes; IndexedEntity (iOS 18+) also puts the entities into Spotlight.
Running: perform() runs in the background by default. Use requestValueDialog so Siri asks for a missing value in your words, and OpenIntent or openAppWhenRun when the action needs the app's UI.
The same intent types drive interactive widgets, controls and the Action button, so one well-designed intent serves every system entry point.
When the payload has no image URL, the guard returns without calling contentHandler, so the system waits out the extension's time limit (about 30 seconds), calls serviceExtensionTimeWillExpire(), and only then shows the push.
Why: once a push carries "mutable-content": 1, the system holds it until the extension hands content back. It cannot tell "nothing to change" from "still working", so a path that returns silently looks like slow work, and the push is shown when the time limit runs out.
The fix: every exit from didReceive must end in exactly one call to contentHandler: on the early path deliver request.content unchanged, on the normal path deliver the edited copy.
Why it hides in testing: serviceExtensionTimeWillExpire() rescues the push, so nothing crashes and nothing is lost; the notification is just late. Send a push without the optional field to a device and time it next to one that has it, and the delay shows at once.
Catch it in review: read every return in didReceive as "who calls the handler here?". Better still, have the server set mutable-content only on pushes that need the extension, so text-only ones never start it.
Caveat: the expiry path and the async path can both fire, so real code should keep the Task and cancel it in serviceExtensionTimeWillExpire() (or guard with a flag) so contentHandler is delivered only once.
Read the NSItemProvider attachments from extensionContext.inputItems, copy the file into the App Group container, and hand the upload to a background URLSession. The extension process ends as soon as you call completeRequest, taking any in-process upload with it.
Getting the file
inputItems holds NSExtensionItems; the payload is in each item's attachments, an array of NSItemProvider.
loadFileRepresentation(for:) hands you a temporary URL that is deleted when the closure returns, so copy the file first. loadDataRepresentation loads everything into memory, and a 200 MB video gets the extension killed; its memory limit is a fraction of the app's.
NSExtensionActivationRule in the extension's Info.plist decides which content types offer your extension. TRUEPREDICATE is for development only and is rejected in App Review.
Handing off the upload
Create the session with URLSessionConfiguration.background(withIdentifier:) and set sharedContainerIdentifier to the App Group; inside an extension the transfer does not work without it.
Background sessions accept only file-backed uploads: uploadTask(with:fromFile:).
The system keeps the transfer running after the extension is gone. When it finishes, it launches the containing app in the background and calls application(_:handleEventsForBackgroundURLSession:completionHandler:); recreate the session with the same identifier there to receive the result.
The backend needs a push-to-start token, which belongs to the activity type and exists before any activity runs, and an update token for each running activity. Both arrive as async sequences and can change, so the app must upload every new value, not just the first.
Tokens
Activity<DeliveryAttributes>.pushToStartTokenUpdates (iOS 17.2+): observe it at launch. It lets the server start an activity while the app is not running.
activity.pushTokenUpdates exists only after the activity starts, whether by request(..., pushType: .token) or by a start push; observe Activity<…>.activityUpdates to catch the ones the server started. Registering only the first token is the classic reason updates stop mid-delivery.
Broadcast updates (iOS 18+): pushType: .channel(_:) subscribes the activity to a channel, so one push updates every viewer of the same match without tracking per-device tokens.
The APNs request
Headers apns-push-type: liveactivity and apns-topic: <bundle-id>.push-type.liveactivity, with token-based (.p8) authentication.
aps carries timestamp, event (start, update, end) and content-state, whose keys must match the Codable ContentState; a start push also needs alert, attributes-type and attributes. With the default JSONDecoder a Date is seconds since 2001 (timeIntervalSinceReferenceDate), so verify on a device or send plain numbers and decode them yourself.
Priority 10 updates are budgeted; send routine ones with apns-priority: 5. Very frequent updates need NSSupportsLiveActivitiesFrequentUpdates, which the user can switch off.
Limits
Payload up to 4 KB. The system ends an activity after 8 hours, and its update token dies with it.
Debug it as the separate process it is: run the widget extension's scheme or attach Xcode to the extension by name, log with Logger instead of print, and read the extension's own crash reports; a blank widget is most often an extension that was killed.
Attaching
Pick the widget extension scheme and Run: Xcode installs the app and attaches when the system launches the extension. The scheme's _XCWidgetKind and _XCWidgetFamily environment variables choose which widget appears.
Or run the app, use Debug > Attach to Process by PID or Name with the extension's executable name, and trigger WidgetCenter.shared.reloadAllTimelines() from the app.
#Preview(as: .systemSmall) with a timeline: block is the fast loop for layout, but it runs in Xcode's preview process, without the real memory limit or entitlements.
Logging
print writes to stdout, which nobody captures for a system-launched process. Logger(subsystem:category:) shows up in Console.app and in Xcode once attached; interpolated strings and objects are redacted as <private> in Console.app unless marked privacy: .public.
Usual causes
Memory kill: the crash log shows EXC_RESOURCE of type memory. Full-resolution images are the typical cause; downsample to the drawn size.
Shared data unreadable: the App Group capability is missing on the extension target or the group id differs, or the file is protected while the device is locked.
A provider that never finishes: a code path that never calls the completion handler, or a slow network call, looks the same as a crash.
Crash reports are filed under the extension's executable: Xcode Organizer > Crashes, or Analytics Data on the device.