“Sent” is not “delivered.” “Delivered” is not “opened.” These are three different states and conflating them causes subtle bugs in badge counts and notification UIs.

The Delivery Gap#

APNs and FCM give you delivery confirmation at the gateway level, not the device level. You know the gateway accepted your payload. You don’t know if the device received it, displayed it, or was offline when it arrived.

For most notifications this is fine. For things like unread counts and “mark all as read” features, you need more precision.

Device-Level Receipts#

The reliable approach: when the app opens a notification (or the user opens the app), send a receipt back to your server. The server marks that notification as confirmed delivered and acknowledged. Keep the receipt payload minimal because it happens on every notification open.

// In your Spring Boot endpoint:
@PostMapping("/notifications/receipt")
public void confirmDelivery(@RequestBody NotificationReceipt receipt) {
    notificationRepository.markDelivered(receipt.getNotificationId(), receipt.getUserId());
}

Badge Count Synchronization#

This is where things get messy across devices. User reads a notification on their phone, the badge count drops to zero. They pick up their tablet: still showing 5. The badge is stale.

The fix: don’t store badge count on the device. Query the server on every app foreground event. The server is the source of truth.

Silent pushes (background notifications) can proactively push badge count updates, but iOS rate-limits them. Polling on foreground is more reliable.

graph TD A[Server Sends Notification] --> B[Gateway Accepts] B --> C[Device Receives] C --> D{User Opens App} D --> E[App Sends Receipt to Server] E --> F[Server Updates Delivery State] D --> G[App Queries Unread Count] G --> H[Server Returns Current Count] H --> I[App Renders Badge] style A fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style B fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style C fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style D fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style E fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style F fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style G fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style H fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style I fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff

At Salesforce#

Badge count desync was a recurring complaint in the mobile app. The count was maintained client-side, incremented on receive and decremented on read. Race conditions during sync meant it drifted. We switched to server-side count and queried on foreground. Desync complaints dropped out of the support queue almost completely.

What I’m Learning#

Keeping any count synchronized across multiple devices is fundamentally a consistency problem. The same trade-offs from quorum reads apply: pick your source of truth and query it, rather than trying to keep replicas in sync.

Have you built read tracking across multiple devices? What edge cases caught you?