A push notification is the short message that lands on a user’s screen even when your app is closed, and technically it has three parts: a token generated on the device, your server that stores that token, and the platform service that actually delivers the message — FCM on Android, APNs on iOS. Your app never sends the notification to the phone directly; it hands the message and the token to the platform, and the platform delivers. That single sentence also explains why this is not as simple as "we added a button and now we send": tokens die, users revoke permission, time zones drift, and one badly targeted campaign can cost you a day of uninstalls. Below you will find the token life cycle, the sending architecture, quiet hours and segmentation in code, pruning dead tokens, when to ask for permission, the consent side, and the five mistakes that turn notifications into spam.
How a notification actually travels
The flow is identical on both platforms and has four steps. When the app opens, the device asks the platform for a token; the app stores that token on your server; when you want to send, you hand the message and the token list to the platform; the platform delivers to the phone or returns an error. The critical part is the last step: there is no delivery guarantee. If the phone is off, battery saving is aggressive, or the user has turned notifications off, the message does not arrive. That is why push is never the only channel for critical information — password resets, order confirmations and payment details must also go by email or SMS.
Push is an attention channel, not a delivery guarantee. "The notification was sent" does not mean "the user saw it" — which is why no business process should ever depend on push alone.
The token life cycle: the part everyone skips
Most projects store the token once and forget it, and six months later half the send list is garbage. A token changes or becomes invalid in five situations:
- The token changes when the app is reinstalled. The old one reaches nobody, but it still sits in your list.
- When a user logs out, the token has to be detached from that user. Otherwise somebody else sharing the phone sees the previous user’s notifications — the most common privacy accident in this area.
- One user has several devices. A token belongs to a device, not a user; the relationship in your data model has to be one-to-many.
- The platform refreshes the token on its own. The app reports this through an event; if you do not update the server on that event, the user quietly becomes unreachable.
- When the app is uninstalled the token becomes "unregistered". You only learn this from the error returned during a send — and if you do not record that error, you never learn it at all.
The practical rule: your token table holds user id, device id, platform, language, UTC offset and last-seen date. Without those six fields you cannot do segmentation, quiet hours or cleanup.
The sending code: batches, quiet hours, dead tokens
A send function has three jobs: split tokens into batches the platform accepts, skip anyone currently inside quiet hours in their own local time, and mark dead tokens based on the errors returned. The function below does all three:
interface DeviceToken {
token: string;
userId: string;
platform: 'android' | 'ios';
/** Offset from UTC in minutes, e.g. 180 for Turkey */
utcOffsetMinutes: number;
}
interface SendResult {
token: string;
ok: boolean;
/** Error code returned by the platform */
error?: 'unregistered' | 'invalid' | 'rate-limited' | 'transient';
}
/** Is the user's local time inside the quiet window? (e.g. 22:00-08:00) */
function inQuietHours(
offsetMinutes: number,
nowUtc: Date,
fromHour = 22,
toHour = 8,
): boolean {
const localMinutes =
(nowUtc.getUTCHours() * 60 + nowUtc.getUTCMinutes() + offsetMinutes + 1440) %
1440;
const hour = Math.floor(localMinutes / 60);
// A window crossing midnight has to be checked as two pieces.
return fromHour <= toHour
? hour >= fromHour && hour < toHour
: hour >= fromHour || hour < toHour;
}
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
export async function sendCampaign(
tokens: DeviceToken[],
message: { title: string; body: string; data?: Record<string, string> },
deps: {
now: Date;
push: (batch: DeviceToken[], msg: typeof message) => Promise<SendResult[]>;
deleteTokens: (tokens: string[]) => Promise<void>;
},
): Promise<{ sent: number; deferred: number; pruned: number; retry: string[] }> {
// 1) Anyone inside quiet hours is not sent to at all — defer to the next window.
const sendable = tokens.filter(
(t) => !inQuietHours(t.utcOffsetMinutes, deps.now),
);
const deferred = tokens.length - sendable.length;
const dead: string[] = [];
const retry: string[] = [];
let sent = 0;
// 2) FCM accepts 500 tokens per request; APNs manages flow per connection.
for (const batch of chunk(sendable, 500)) {
const results = await deps.push(batch, message);
for (const r of results) {
if (r.ok) {
sent += 1;
} else if (r.error === 'unregistered' || r.error === 'invalid') {
dead.push(r.token); // app uninstalled or token corrupt -> delete
} else {
retry.push(r.token); // transient error -> queue with backoff
}
}
}
// 3) Prune dead tokens immediately, or the list gets dirtier every campaign.
if (dead.length > 0) await deps.deleteTokens(dead);
return { sent, deferred, pruned: dead.length, retry };
}The four numbers this function returns are not there by accident: sent, deferred, pruned and to-retry. Without recording all four you cannot answer "why did nobody see it". Retries for transient errors also need exponential backoff; resending the same list immediately is the fastest way to hit a platform rate limit.
Tokens or topics?
There are two sending models, and the choice depends on whether the message belongs to a person or to a crowd:
- Token-based — to specific devices. Everything personal goes this way: your order is on the way, you have a message, your appointment is tomorrow. Segmentation happens in your own database, so there is no limit to it.
- Topic-based — to everyone subscribed to a topic. Right for mass content such as announcements, campaigns or match scores; one request reaches millions and you do not have to keep a token list. In exchange, you cannot tell who received it.
- A hybrid is the most common arrangement in practice: mass announcements by topic, personal notifications by token. Users also need a preference screen to manage which topics they are subscribed to.
When should you ask for permission?
This is the single most expensive decision in a notification project. On iOS and recent Android versions notification permission requires explicit consent, and once a user has declined it is almost impossible to reverse — they would have to go into settings, and they will not. That is why permission is never requested on first launch.
- Tie the request to a value. Instead of "allow notifications", ask "shall we tell you when your order ships" — and show the prompt at the moment the user engages with that feature.
- Show your own screen first. A pre-prompt explaining what you will send raises acceptance markedly, and a user who declines it has not spent the system permission at all.
- Record the refusal and respect it. Asking again on every launch causes problems with store policies and is a direct cause of uninstalls.
- Build an in-app notification centre. Even with push off, messages should be visible in a list inside the app; otherwise a user who declined sees no announcements ever.
Marketing messages and the consent side
Two different permissions get confused here: the operating system’s notification permission is a technical one, and the consent required for commercial electronic messages is a legal one. Notifications being enabled on someone’s phone does not mean you may send them campaigns. In practice three things are needed: transactional notifications (orders, appointments, security) kept separate from marketing ones in the data model, marketing consent recorded with its date and channel, and the ability for users to switch off notifications by type. A single "turn off notifications" toggle also turns off order updates and loses the user twice over. We covered the wider personal-data framework in building a compliant website.
What to send, and what not to
The value of a notification is measured by relevance, not frequency. Every notification that works shares one property: it reports an event the user was already waiting for.
- Transactional — order status, shipping movement, appointment reminders, payment information. The highest open rates live here, because the user expects them.
- Triggered — tied to the user’s own behaviour: the item left in your basket, the listing you follow was updated, a new record matches your search. Set up well, far more effective than campaigns.
- Mass campaigns — the lowest conversion and the highest uninstall risk. An unsegmented campaign is what burns an app’s notification permission.
- Silent notifications — invisible to the user, used so the app can refresh data in the background. Useful, but constrained by battery-saving rules; the lowest delivery rate of all types.
Where notifications sit on the revenue side has to be designed together with your subscription and campaign model; we compared the options in mobile app revenue models. Store-side visibility is a separate job, covered in what ASO is.
Measurement: which numbers matter?
The number of notifications sent is not a measure of success. Four numbers matter and they are read together: delivery rate (how many of the sent messages reached a phone), open rate (how many were tapped), opt-in rate (what share of users have notifications enabled), and uninstall or opt-out rate. The fourth balances the other three: an aggressive campaign with a flattering open rate is a net loss if it lowers your opt-in rate in the same month.
Cost and ongoing load
- The platform services (FCM, APNs) do not charge per message; the cost sits on your own server and in whatever campaign tool you use.
- A ready-made tool (sending console, segmentation, A/B testing) comes as a subscription and is the right choice for the marketing side of the team; campaigns can be set up without engineers.
- Building your own means a token table, a queue, retries with backoff and reporting. That is the right route if you do not want personal data leaving your systems, or if segmentation is tightly coupled to your own database.
- Ongoing load: notifications are not written once and finished; platform releases keep changing permission behaviour. Budget the maintenance line from the start — we broke the items down in mobile app maintenance costs.
Five mistakes that turn notifications into spam
- Sending everyone the same message. An unsegmented campaign is the fastest way to drop your opt-in rate, and a user who switches off does not come back.
- Ignoring time zones. A "campaign" sent on server time lands at three in the morning for part of your users, and that send produces uninstalls directly.
- Not pruning dead tokens. The list bloats, reports come out wrong, and you creep toward platform rate limits for nothing.
- Offering a single off switch. A user trying to mute marketing also mutes order updates; preferences have to be per notification type.
- Skipping where the notification leads. A notification that opens the app on the home screen wastes the message itself; every one should deep-link to the relevant screen.
Conclusion
Push notifications are a small feature technically and a large one operationally: the hard part is not sending the message but keeping the token alive, reaching the right person at the right local hour, and not burning the permission. So start with the token life cycle and a per-type preference screen, and leave campaigns for last. If your mass-announcement needs are simple, a ready-made console is enough; if segmentation is tightly coupled to your own data or you do not want personal data leaving your systems, you will need to build it yourself. If you are planning to add notifications to your app, take a look at our mobile app service or request a quote with a note about your user count and the notification types you want to send.