When Push Notifications Reach Only Some Users
I once sent an announcement by push for one of my indie apps and the open count came in far below what I expected. The send log said "sent to everyone." Yet a few people wrote to say no notification arrived, and on one of my own spare devices it hadn't shown up either. Sent but not arriving — at the time I could only explain that vague state as "APNs must be acting up."
What I learned later is that "not arriving" is not a single phenomenon. The token is stale, the server rejected acceptance, the platform throttled it as low priority, the device didn't display it — all produce the same "didn't arrive," but the cause and the fix are completely different. This article shares the design — with implementation — for observing notifications across the stages from send to display, isolating where they drop, and closing the delivery gap.
Decompose "Didn't Arrive" Into Stages
The first move is to split one "didn't arrive" into stages that can each fail independently. Put an observation point at each stage and a vague malfunction becomes "N dropped at this stage."
| Stage | Main reasons it drops | Signal to check |
|---|---|---|
| 1. Token acquisition | Not permitted / token not registered | Device permission status, server registration |
| 2. Server acceptance | Stale token, bad payload | APNs/FCM response code |
| 3. Platform delivery | Low-priority throttling | Priority setting, message type |
| 4. Device display | Force-quit, notifications off, silent handling | Receive-handler arrival log |
| 5. Open | Unnoticed / not interested | Open event |
Most developers watch only stage 5 (open rate). But much of "didn't arrive" happens at stages 2–4, and without observing those you can't tell whether a low open rate means "the content is bad" or "it never arrived at all." Below, I knock down the most failure-prone stages in order.
Clean Up Stale Tokens (the Quietest Killer)
In production the most common cause of "didn't arrive" is the server sending to old tokens forever. When a user deletes the app, reinstalls, or updates the OS, the token changes. Send to an old token and APNs/FCM return "invalid" at acceptance or in a later receipt — but if you don't record and clean those up, you keep sending to dead addresses indefinitely.
Design tokens as a pair: not just "register" but "invalidate."
// register-token.ts — device side: register a token and update on change
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
export async function registerPushToken(userId: string) {
if (!Device.isDevice) return; // simulators can't get a real token
const { status } = await Notifications.getPermissionsAsync();
let finalStatus = status;
if (status !== "granted") {
finalStatus = (await Notifications.requestPermissionsAsync()).status;
}
if (finalStatus !== "granted") {
// Drops at stage 1: not permitted. Record on the server as "cannot send"
await fetch("https://api.example.com/push/unregister", {
method: "POST",
body: JSON.stringify({ userId, reason: "denied" }),
headers: { "Content-Type": "application/json" },
});
return;
}
const token = (await Notifications.getExpoPushTokenAsync()).data;
await fetch("https://api.example.com/push/register", {
method: "POST",
body: JSON.stringify({ userId, token, platform: Device.osName }),
headers: { "Content-Type": "application/json" },
});
}On the server, always record the failure code returned on each send and stop invalid tokens. With Expo's push API, a token whose receipt returns DeviceNotRegistered should be invalidated immediately.
// send.mjs — server side: send, and clean tokens by failure code
import { Expo } from "expo-server-sdk";
const expo = new Expo();
export async function sendBatch(messages) {
const chunks = expo.chunkPushNotifications(messages);
const stats = { accepted: 0, rejected: 0, invalidTokens: [] };
for (const chunk of chunks) {
const tickets = await expo.sendPushNotificationsAsync(chunk);
tickets.forEach((t, i) => {
if (t.status === "ok") {
stats.accepted++;
} else {
stats.rejected++;
// Dropped at stage 2. Always keep the reason code
if (t.details?.error === "DeviceNotRegistered") {
stats.invalidTokens.push(chunk[i].to); // delete from DB later
}
console.error(`reject: ${t.details?.error} token=${chunk[i].to}`);
}
});
}
// Clean invalid tokens (don't send to these addresses next time)
await purgeTokens(stats.invalidTokens);
return stats;
}The key is keeping accepted and rejected as recorded metrics. Instead of "sent to everyone," knowing "N accepted, M rejected, K of them stale" turns stage-2 loss into a number. I log these three on every send and watch whether the share of stale tokens spikes. A sudden rise usually traces back to a major OS update or a surge of reinstalls.