Widgets are the cheapest real estate on a phone. A delivery app that shows the courier's ETA on the lock screen, a field-service app that puts today's first job on the home screen, a finance app with a balance tile — all of them get opened more often than the same app without a widget. In 2026 the two features clients ask us for by name are iOS Live Activities (lock screen and Dynamic Island) and home screen widgets on both platforms.
Neither one runs in your WebView. Widgets and Live Activities are native
processes with their own lifecycle: SwiftUI/WidgetKit on iOS, Glance or
AppWidgetProvider on Android. Your Ionic app cannot render them, but it can
feed them — and that plumbing is the whole job. This tutorial builds it end to
end for a Capacitor app: shared storage, a small Capacitor plugin, a WidgetKit
timeline, an Android Glance widget, and a Live Activity that a server can update
by push while the app is closed.
Examples are Ionic Angular, but nothing in the TypeScript layer is framework-specific.
The mental model
Ionic app (WebView)
| Capacitor plugin call
v
Native shared store <-- App Group / SharedPreferences --> Widget extension
^ ^
| |
+---------------- APNs push (Live Activity update) ---------------+
Three rules follow from this diagram, and every widget bug we get called in to fix breaks one of them:
- The widget never calls your API on demand. It reads a small snapshot written by the app (or delivered by push). Widget processes get a few seconds of CPU and can be killed mid-flight.
- The snapshot is a plain, versioned struct — no JWTs, no HTML, no megabyte JSON blobs. On iOS the App Group container is readable by anything in the group; treat it as semi-public.
- Refresh budgets are real. WidgetKit gives you on the order of a few dozen timeline refreshes a day. Design a timeline, not a polling loop.
1. Define the snapshot once, in TypeScript
Start from the contract. One interface, one version field, shared by app, plugin and both native widgets.
// src/app/widget/snapshot.ts
export interface JobSnapshot {
v: 1;
title: string; // "Boiler service — 14 Mill St"
startsAtIso: string; // ISO 8601, UTC
status: 'scheduled' | 'enroute' | 'onsite' | 'done';
countToday: number;
updatedAtIso: string;
}
export function encodeSnapshot(s: JobSnapshot): string {
return JSON.stringify(s);
}
Keep it under a kilobyte or two. If you find yourself adding arrays of items, you are building a second app inside a widget; ship the summary instead.
2. A thin Capacitor plugin
You only need two methods: write the snapshot, and ask the OS to reload the
widget. Generate the scaffold with npm init @capacitor/plugin, then define the
API:
// packages/widget-bridge/src/definitions.ts
export interface WidgetBridgePlugin {
setSnapshot(options: { json: string }): Promise<void>;
reload(): Promise<void>;
startLiveActivity(options: { json: string }): Promise<{ pushToken?: string }>;
endLiveActivity(): Promise<void>;
}
iOS side
Create an App Group (group.com.example.app) and enable it on both the app
target and the widget extension target — this is the step teams miss, and the
symptom is a widget that only ever shows placeholder data.
// ios/Plugin/WidgetBridgePlugin.swift
import Capacitor
import WidgetKit
@objc(WidgetBridgePlugin)
public class WidgetBridgePlugin: CAPPlugin {
private let suite = UserDefaults(suiteName: "group.com.example.app")
@objc func setSnapshot(_ call: CAPPluginCall) {
guard let json = call.getString("json") else {
return call.reject("json required")
}
suite?.set(json, forKey: "jobSnapshot")
call.resolve()
}
@objc func reload(_ call: CAPPluginCall) {
WidgetCenter.shared.reloadTimelines(ofKind: "JobWidget")
call.resolve()
}
}
Android side
SharedPreferences is not shared across processes reliably, but a widget runs
in your own app process, so plain prefs plus a broadcast is enough.
@CapacitorPlugin(name = "WidgetBridge")
class WidgetBridgePlugin : Plugin() {
@PluginMethod
fun setSnapshot(call: PluginCall) {
val json = call.getString("json") ?: return call.reject("json required")
context.getSharedPreferences("widget", Context.MODE_PRIVATE)
.edit().putString("jobSnapshot", json).apply()
call.resolve()
}
@PluginMethod
fun reload(call: PluginCall) {
val mgr = AppWidgetManager.getInstance(context)
val ids = mgr.getAppWidgetIds(ComponentName(context, JobWidgetReceiver::class.java))
mgr.notifyAppWidgetViewDataChanged(ids, R.id.widget_root)
JobWidgetReceiver().onUpdate(context, mgr, ids)
call.resolve()
}
}
3. Write the snapshot at the right moments
Do not write on every state change; write when the user-visible summary changes, and always on resume and on pause.
@Injectable({ providedIn: 'root' })
export class WidgetService {
private last = '';
async publish(s: JobSnapshot) {
const json = encodeSnapshot(s);
if (json === this.last) return; // avoid burning refreshes
this.last = json;
await WidgetBridge.setSnapshot({ json });
await WidgetBridge.reload();
}
}
// app.component.ts
App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) this.widgets.publish(this.jobs.currentSnapshot());
});
Writing on pause matters: that is the moment the user is about to look at the
home screen.
4. The WidgetKit timeline
The widget decodes the snapshot and produces a timeline — entries with future dates, so the ETA keeps counting down without any refresh at all.
struct JobEntry: TimelineEntry { let date: Date; let snapshot: JobSnapshot? }
struct JobProvider: TimelineProvider {
func placeholder(in _: Context) -> JobEntry { JobEntry(date: .now, snapshot: nil) }
func getSnapshot(in _: Context, completion: @escaping (JobEntry) -> Void) {
completion(JobEntry(date: .now, snapshot: load()))
}
func getTimeline(in _: Context, completion: @escaping (Timeline<JobEntry>) -> Void) {
let snap = load()
var entries = [JobEntry(date: .now, snapshot: snap)]
// one extra entry at the job start time, then ask to be refreshed
if let s = snap, let start = ISO8601DateFormatter().date(from: s.startsAtIso),
start > .now {
entries.append(JobEntry(date: start, snapshot: snap))
}
completion(Timeline(entries: entries, policy: .after(.now.addingTimeInterval(1800))))
}
private func load() -> JobSnapshot? {
guard let json = UserDefaults(suiteName: "group.com.example.app")?
.string(forKey: "jobSnapshot"),
let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(JobSnapshot.self, from: data)
}
}
Use Text(date, style: .relative) and Text(date, style: .timer) in the view
for live-looking countdowns that cost you nothing. And add a
widgetURL(URL(string: "example://job/\(id)")) so tapping the widget deep-links
into the app — which only works if you have already wired Universal Links and
App Links properly.
5. Android: Glance in ~30 lines
class JobWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val json = context.getSharedPreferences("widget", Context.MODE_PRIVATE)
.getString("jobSnapshot", null)
val snap = json?.let { Json.decodeFromString<JobSnapshot>(it) }
provideContent {
Column(modifier = GlanceModifier.padding(12.dp)) {
Text(snap?.title ?: "No jobs today")
Text("${snap?.countToday ?: 0} jobs")
}
}
}
}
class JobWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = JobWidget()
}
Register the receiver in AndroidManifest.xml with an
android.appwidget.provider meta-data pointing at an XML descriptor that sets
targetCellWidth/targetCellHeight and a previewLayout — Android 12+ shows
that preview in the widget picker, and a missing one looks broken.
6. Live Activities: the part that needs a server
A Live Activity is started by the app, in the foreground, and then updated either locally or — the useful case — by push, so the lock screen stays correct while your app is suspended.
@objc func startLiveActivity(_ call: CAPPluginCall) {
guard let json = call.getString("json"), let data = json.data(using: .utf8),
let snap = try? JSONDecoder().decode(JobSnapshot.self, from: data)
else { return call.reject("bad snapshot") }
do {
let activity = try Activity<JobAttributes>.request(
attributes: JobAttributes(title: snap.title),
content: .init(state: .init(status: snap.status,
startsAt: ISO8601DateFormatter().date(from: snap.startsAtIso) ?? .now),
staleDate: .now.addingTimeInterval(3600)),
pushType: .token
)
Task {
for await token in activity.pushTokenUpdates {
let hex = token.map { String(format: "%02x", $0) }.joined()
self.notifyListeners("liveActivityToken", data: ["token": hex])
}
}
call.resolve()
} catch { call.reject(error.localizedDescription) }
}
Checklist for the iOS target: NSSupportsLiveActivities = true in
Info.plist, a Widget Extension containing the ActivityConfiguration, and
ActivityAuthorizationInfo().areActivitiesEnabled checked before you request —
users can switch Live Activities off per app.
In the app, forward the push token to your backend:
WidgetBridge.addListener('liveActivityToken', ({ token }) =>
this.api.post('/live-activities', { token, jobId: this.jobId }),
);
The server then pushes updates to APNs with apns-push-type: liveactivity,
topic <bundle-id>.push-type.liveactivity, and a payload whose
content-state matches your ContentState struct exactly:
{
"aps": {
"timestamp": 1767225600,
"event": "update",
"content-state": { "status": "enroute", "startsAt": 1767229200 },
"alert": { "title": "Courier on the way", "body": "ETA 14:20" }
}
}
Send "event": "end" with a dismissal-date when the job finishes. Activities
that are never ended sit on the lock screen for hours and generate support
tickets.
Android's closest equivalent is an ongoing notification with a progress bar (and
Notification.CallStyle/ProgressStyle variants on Android 16). Plan for two
implementations behind one TypeScript API rather than pretending the platforms
match.
7. Testing and the traps
- Widget shows placeholder forever → App Group not enabled on the extension
target, or a
Codablemismatch. Add adebugDescriptionfallback string to the snapshot while developing. - Stale data after login/logout → clear the snapshot on sign-out. A widget that still shows the previous user's data is a privacy incident, not a bug.
- Nothing updates in the background → that is correct behaviour. If the data must be live while the app is closed, it has to arrive by push.
- Timeline refreshes exhausted → drop the refresh policy to
.after(30 min)and rely onText(style: .timer)for anything that just counts. - On device, test with the app force-quit, in Low Power Mode, and with the phone locked. Those three states are where widget code fails.
- Snapshot the widget in your CI screenshot suite; SwiftUI previews render widget families without a device.
Where the effort actually goes
The native code above is a day or two of work. The real cost is deciding what single number or line of text belongs on the lock screen, and making sure your domain model can produce it cheaply and correctly at any moment — including offline. Teams that get widgets right usually already have a clean local state layer feeding the UI; teams that don't end up adding a widget-shaped API endpoint and a race condition.
If you want this built and reviewed properly on your Ionic app — the App Group setup, the plugin, the APNs Live Activity pipeline and the store review notes that come with it — get in touch. We do this on Ionic and Capacitor codebases every week.