Push notification backend work is one of the most underrated problems in iOS development. The client side looks trivial (grab a device token, send to APNs), but weird issues surface in production at scale. Users getting the same notification twice, users getting nothing, users getting it late.
I send push across all 12 of my iOS apps, and I’ve hit different flavours of the same bugs in every project. Here’s what I’ve learned about retry and dedupe from real production traffic.
What APNs does and doesn’t guarantee
Apple Push Notification Service is explicit about what it won’t promise:
1. At-most-once delivery. Apple says “we’ll try once, maybe not at all”. There is no delivery guarantee.
2. No FIFO ordering. Send three notifications to the same device and they may arrive out of order.
3. Offline devices get collapsed. If the device is offline, APNs holds the last notification and drops the earlier ones by default. You can override this with collapse-id.
4. Token refresh. When a user uninstalls and reinstalls the app, the token changes. Anything sent to the old token is lost.
Understanding these behaviours changes how you design the backend.
Why users see duplicates
The common causes:
1. Bad retry logic. The backend sent to APNs, the network timed out, the backend retried. But the first request actually succeeded. The user gets two notifications.
2. Multiple token registrations. Old token, new token, both still in the database. The same message goes to both.
3. Race conditions. Two parallel workers fire on the same event. Both send a notification.
4. No client-side dedupe. APNs occasionally delivers the same payload twice (rare but it happens). Without client dedupe the user sees it twice.
Solution 1: a unique notification ID
Give every notification a unique ID. Mark it as sent on the backend:
{
"aps": { ... },
"notification_id": "550e8400-e29b-41d4-a716",
"event_id": "order_placed_123"
}Backend send logic:
- Generate a unique notification_id (UUID v4)
- Mark it as “sent” in Redis or your DB (60 minute TTL)
- Send to APNs
- On success, the marker stays
- On failure, clear the marker so retry is eligible
Now if a second send fires for the same event, you check first: “have I already sent for this event_id?” If yes, skip.
Solution 2: client-side deduplication
Add a guard on the client too. Check inside UserNotificationCenter:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
guard let id = userInfo["notification_id"] as? String else {
completionHandler([.banner, .sound])
return
}
if Self.seenNotifications.contains(id) {
completionHandler([]) // Do not present
return
}
Self.seenNotifications.insert(id)
completionHandler([.banner, .sound])
}seenNotifications is a Set<String> holding the last 100 to 200 notification IDs (memory or UserDefaults).
Solution 3: the retry strategy
For the backend send-and-retry logic:
Exponential backoff: 1s, 2s, 4s, 8s, 16s, max five attempts.
Handle each failure mode differently:
– 400 BadDeviceToken: token is invalid, delete from DB, do not retry
– 410 Unregistered: the user uninstalled, delete the token
– 503 ServiceUnavailable: APNs hiccup, retry
– 429 TooManyRequests: rate limited, exponential backoff
– timeout: retry, but the notification_id check matters here
The 410 response is the critical one. When the user uninstalls, the token goes invalid but stays in your DB. Every subsequent send fails and retries. Clean up invalid tokens aggressively.
Solution 4: rationalise with collapse-id
Collapse-id groups multiple notifications on the same topic. Example: a chat app:
Header: apns-collapse-id: thread-123Five messages in the same thread? The iPhone lock screen shows only the most recent one. The earlier four are pushed aside.
This is a much better experience. No spam feel, the information is current.
“I’m not getting notifications”
The other side of the problem: the user tells you nothing is coming through. Possible causes:
1. Token expired. After 30 days of inactivity, the token can be invalidated. When the user opens the app, a new token registers.
2. Notification permission revoked. The user turned them off in Settings. Your backend doesn’t know, so it keeps sending into the void.
3. Focus mode. iOS 15+ Focus filters notifications. From the user’s perspective, your notification “never came”.
4. Silent mode or no badge. Badge updates but no sound. Some users read that as “no notification”.
Troubleshooting: keep APNs responses in your backend logs. When a user complains:
– 30 day notification log: how many sent, what responses came back?
– When was the token last updated?
– Any 410 errors?
Testing
The sandbox APNs environment is perfect for debugging. Separate from production tokens. Xcode debug builds go to sandbox by default.
My test checklist:
- App in foreground: does the notification land? (Is willPresent called?)
- App in background: does the banner appear?
- App killed: does it launch?
- Duplicate test: trigger the same event three times. Does the user get exactly one notification?
- Invalid token: do you get 410 and is the token deleted?
- Network timeout: does retry fire without creating a duplicate?
Monitoring
The metrics worth tracking in production:
- Send rate: how many notifications per hour?
- Success rate: APNs 200 response ratio
- 410 rate: invalid token ratio (rising means your cleanup is lagging)
- Retry rate: how many retries fire, how many succeed
- Latency: time from queueing on the backend to delivery
Those five go on a dashboard. Anomalies should be obvious at a glance.
Takeaway
The push subsystem is more complex than it looks. Unique IDs for dedupe, exponential backoff on retries, aggressive cleanup of invalid tokens, collapse-id to rationalise. Those four patterns cut spam and raise delivery rate.
A simple fire-and-forget is fine on v1. Once you cross 10,000 users, roll these in, or the complaints get serious.