home / skills / kaakati / rails-enterprise-dev / push-notifications
This skill provides expert push notification decisions for permission timing, delivery strategies, and architecture choices to optimize user engagement.
npx playbooks add skill kaakati/rails-enterprise-dev --skill push-notificationsReview the files below or copy the command above to add this skill to your agents.
---
name: push-notifications
description: "Expert notification decisions for iOS/tvOS: when to request permission, silent vs visible notification trade-offs, rich notification strategies, and APNs architecture choices. Use when implementing push notifications, debugging delivery issues, or designing notification UX. Trigger keywords: push notification, UNUserNotificationCenter, APNs, device token, silent notification, content-available, mutable-content, notification extension, notification actions, badge"
version: "3.0.0"
---
# Push Notifications — Expert Decisions
Expert decision frameworks for notification choices. Claude knows UNUserNotificationCenter and APNs — this skill provides judgment calls for permission timing, delivery strategies, and architecture trade-offs.
---
## Decision Trees
### Permission Request Timing
```
When should you ask for notification permission?
├─ User explicitly wants notifications
│ └─ After user taps "Enable Notifications" button
│ Highest acceptance rate (70-80%)
│
├─ After demonstrating value
│ └─ After user completes key action
│ "Get notified when your order ships?"
│ Context-specific, 50-60% acceptance
│
├─ First meaningful moment
│ └─ After onboarding, before home screen
│ Explain why, 30-40% acceptance
│
└─ On app launch
└─ AVOID — lowest acceptance (15-20%)
No context, feels intrusive
```
**The trap**: Requesting permission on first launch. Users deny reflexively. Wait for a moment when notifications clearly add value.
### Silent vs Visible Notification
```
What's the notification purpose?
├─ Background data sync
│ └─ Silent notification (content-available: 1)
│ No user interruption, wakes app
│
├─ User needs to know immediately
│ └─ Visible alert
│ Messages, time-sensitive info
│
├─ Informational, not urgent
│ └─ Badge + silent
│ User sees count, checks when ready
│
└─ Needs user action
└─ Visible with actions
Reply, accept/decline buttons
```
### Notification Extension Strategy
```
Do you need to modify notifications?
├─ Download images/media
│ └─ Notification Service Extension
│ mutable-content: 1 in payload
│
├─ Decrypt end-to-end encrypted content
│ └─ Notification Service Extension
│ Required for E2EE messaging
│
├─ Custom notification UI
│ └─ Notification Content Extension
│ Long-press/3D Touch custom view
│
└─ Standard text/badge
└─ No extension needed
Less complexity, faster delivery
```
### Token Management
```
How should you handle device tokens?
├─ Single device per user
│ └─ Replace token on registration
│ Simple, most apps need this
│
├─ Multiple devices per user
│ └─ Register all tokens
│ Send to all active devices
│
├─ Token changed (reinstall/restore)
│ └─ Deduplicate on server
│ Same device, new token
│
└─ User logged out
└─ Deregister token from user
Prevents notifications to wrong user
```
---
## NEVER Do
### Permission Handling
**NEVER** request permission without context:
```swift
// ❌ First thing on app launch — user denies
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
return true
}
// ✅ After user action that demonstrates value
func userTappedEnableNotifications() {
showPrePermissionExplanation {
Task {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted == true {
await MainActor.run { registerForRemoteNotifications() }
}
}
}
}
```
**NEVER** ignore denied permission:
```swift
// ❌ Keeps trying, annoys user
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
if settings.authorizationStatus == .denied {
// Ask again! <- User already said no
requestPermission()
}
}
}
// ✅ Respect denial, offer settings path
func checkNotifications() {
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .denied:
showSettingsPrompt() // "Enable in Settings to receive..."
case .notDetermined:
showPrePermissionScreen()
case .authorized, .provisional, .ephemeral:
ensureRegistered()
@unknown default:
break
}
}
}
```
### Token Handling
**NEVER** cache device tokens long-term in app:
```swift
// ❌ Token may change without app knowing
class TokenManager {
static var cachedToken: String? // Stale after reinstall!
func getToken() -> String? {
return Self.cachedToken // May be invalid
}
}
// ✅ Always use fresh token from registration callback
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.hexString
// Send to server immediately — this is the source of truth
Task {
await sendTokenToServer(token)
}
}
```
**NEVER** assume token format:
```swift
// ❌ Token format is not guaranteed
let tokenString = String(data: deviceToken, encoding: .utf8) // Returns nil!
// ✅ Convert bytes to hex
extension Data {
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}
let tokenString = deviceToken.hexString
```
### Silent Notifications
**NEVER** rely on silent notifications for time-critical delivery:
```swift
// ❌ Silent notifications are low priority
// Server sends: {"aps": {"content-available": 1}}
// Expecting: Immediate delivery
// Reality: iOS may delay minutes/hours or drop entirely
// ✅ Use visible notification for time-critical content
// Or use silent for prefetch, visible for alert
{
"aps": {
"alert": {"title": "New Message", "body": "..."},
"content-available": 1 // Also prefetch in background
}
}
```
**NEVER** do heavy work in silent notification handler:
```swift
// ❌ System will kill your app
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
await downloadLargeFiles() // Takes too long!
await processAllData() // iOS terminates app
return .newData
}
// ✅ Quick fetch, defer heavy processing
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
// 30 seconds max — fetch metadata only
do {
let hasNew = try await checkForNewContent()
if hasNew {
scheduleBackgroundProcessing() // BGProcessingTask
}
return hasNew ? .newData : .noData
} catch {
return .failed
}
}
```
### Notification Service Extension
**NEVER** forget expiration handler:
```swift
// ❌ System shows unmodified notification
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
// Start async work...
downloadImage { image in
// Never called if timeout!
contentHandler(modifiedContent)
}
}
// Missing serviceExtensionTimeWillExpire!
}
// ✅ Always implement expiration handler
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
downloadImage { [weak self] image in
guard let self, let content = self.bestAttemptContent else { return }
if let image { content.attachments = [image] }
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
// Called ~30 seconds — deliver what you have
if let content = bestAttemptContent {
contentHandler?(content)
}
}
}
```
---
## Essential Patterns
### Permission Flow with Pre-Permission
```swift
@MainActor
final class NotificationPermissionManager: ObservableObject {
@Published var status: UNAuthorizationStatus = .notDetermined
func checkStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
status = settings.authorizationStatus
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
UIApplication.shared.registerForRemoteNotifications()
}
await checkStatus()
return granted
} catch {
return false
}
}
func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
// Pre-permission screen
struct NotificationPermissionView: View {
@StateObject private var manager = NotificationPermissionManager()
@State private var showSystemPrompt = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "bell.badge")
.font(.system(size: 60))
Text("Stay Updated")
.font(.title)
Text("Get notified about new messages, order updates, and important alerts.")
.multilineTextAlignment(.center)
Button("Enable Notifications") {
Task { await manager.requestPermission() }
}
.buttonStyle(.borderedProminent)
Button("Not Now") { dismiss() }
.foregroundColor(.secondary)
}
.padding()
}
}
```
### Notification Action Handler
```swift
@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationHandler()
private let router: DeepLinkRouter
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
// App is in foreground
let userInfo = notification.request.content.userInfo
// Check if we should show banner or handle silently
if shouldShowInForeground(userInfo) {
return [.banner, .sound, .badge]
} else {
handleSilently(userInfo)
return []
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped notification
await handleNotificationTap(userInfo)
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
await handleReply(text: textResponse.userText, userInfo: userInfo)
}
case "MARK_READ_ACTION":
await markAsRead(userInfo)
case UNNotificationDismissActionIdentifier:
// User dismissed
break
default:
await handleCustomAction(response.actionIdentifier, userInfo: userInfo)
}
}
private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) async {
guard let deepLink = userInfo["deep_link"] as? String,
let url = URL(string: deepLink) else { return }
await router.navigate(to: url)
}
}
```
### Rich Notification Service
```swift
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
Task {
// Download and attach media
if let mediaURL = request.content.userInfo["media_url"] as? String {
if let attachment = await downloadAttachment(from: mediaURL) {
content.attachments = [attachment]
}
}
// Decrypt if needed
if let encrypted = request.content.userInfo["encrypted_body"] as? String {
content.body = decrypt(encrypted)
}
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let content = bestAttemptContent {
contentHandler?(content)
}
}
private func downloadAttachment(from urlString: String) async -> UNNotificationAttachment? {
guard let url = URL(string: urlString) else { return nil }
do {
let (localURL, response) = try await URLSession.shared.download(from: url)
let fileExtension = (response as? HTTPURLResponse)?
.mimeType.flatMap { mimeToExtension[$0] } ?? "jpg"
let destURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.moveItem(at: localURL, to: destURL)
return try UNNotificationAttachment(identifier: "media", url: destURL)
} catch {
return nil
}
}
}
```
---
## Quick Reference
### Payload Structure
| Field | Purpose | Value |
|-------|---------|-------|
| alert | Visible notification | {title, subtitle, body} |
| badge | App icon badge | Number |
| sound | Notification sound | "default" or filename |
| content-available | Silent/background | 1 |
| mutable-content | Service extension | 1 |
| category | Action buttons | Category identifier |
| thread-id | Notification grouping | Thread identifier |
### Permission States
| Status | Meaning | Action |
|--------|---------|--------|
| notDetermined | Never asked | Show pre-permission |
| denied | User declined | Show settings prompt |
| authorized | Full access | Register for remote |
| provisional | Quiet delivery | Consider upgrade prompt |
| ephemeral | App clip temporary | Limited time |
### Extension Limits
| Extension | Time Limit | Use Case |
|-----------|------------|----------|
| Service Extension | ~30 seconds | Download media, decrypt |
| Content Extension | User interaction | Custom UI |
| Background fetch | ~30 seconds | Data refresh |
### Red Flags
| Smell | Problem | Fix |
|-------|---------|-----|
| Permission on launch | Low acceptance | Wait for user action |
| Cached device token | May be stale | Always use callback |
| String(data:encoding:) for token | Returns nil | Use hex encoding |
| Silent for time-critical | May be delayed | Use visible notification |
| Heavy work in silent handler | App terminated | Quick fetch, defer work |
| No serviceExtensionTimeWillExpire | Unmodified content shown | Always implement |
| Ignoring denied status | Frustrates user | Offer settings path |
This skill provides expert decision-making and practical patterns for iOS/tvOS push notifications, covering permission timing, silent vs visible trade-offs, rich notifications, token handling, and APNs architecture choices. It focuses on actionable guidance you can apply while implementing notifications, debugging delivery issues, or designing notification UX. The content emphasizes safe defaults and common pitfalls to avoid.
The skill inspects notification intent and delivery constraints to recommend the right approach: when to show a pre-permission flow, whether a payload should be silent (content-available) or visible, and when to use notification service/content extensions. It evaluates token management strategies, background execution limits, and APNs priorities to produce concrete implementation decisions. It also supplies patterns for handling authorization states, expiration in service extensions, and notification action routing.
When should I ask for notification permission?
Ask after a user action that demonstrates clear value (e.g., enabling order updates) or at the first meaningful moment with a pre-permission explanation. Avoid asking at app launch.
Can I use silent notifications for immediate delivery?
No. Silent notifications are best-effort and may be delayed or dropped. Use visible notifications for time-critical content and reserve silent payloads for background prefetch.