Push notifications are either a feature users love (“that reminder saved me”) or the fastest way to get muted, uninstalled, and ignored. The difference is rarely the UI alone—it’s the whole system: token lifecycle, audience targeting, payload design, and debuggability. This guide shows a practical setup: when to use tokens vs topics, how to avoid deliverability pitfalls, and how to build UX that earns permission.
Quickstart
If you want immediate improvements (today), start here. These steps work whether you’re using Firebase Cloud Messaging (FCM), APNs directly, or a provider—because they focus on fundamentals: permission timing, segmentation, and a traceable send pipeline.
1) Fix your permission prompt UX
Ask only after the user sees value. Add a preference center so “yes” doesn’t mean “spam”.
- Show a pre-prompt explaining the benefit (one sentence)
- Request permission after a meaningful action (checkout, save, follow)
- Offer notification categories (news, updates, reminders) in settings
- Respect quiet hours and frequency caps
2) Treat tokens like expiring credentials
Device tokens can rotate or become invalid. Build refresh + cleanup from day one.
- Capture token refresh events and re-sync to backend
- Store token with platform, app version, locale, and user ID (if logged in)
- On send errors, deactivate tokens and stop retrying forever
- Keep an opt-out flag independent of token presence
3) Use topics for broad audiences, tokens for precision
Most systems fail by overusing raw tokens for segmentation. Let topics do the heavy lifting.
- Use topics for “everyone interested in X” (e.g., sports_updates)
- Use tokens for 1:1 or small groups (e.g., your order shipped)
- Keep topics human-readable and stable (avoid per-user topics)
- Gate topic subscription behind a user choice
4) Make delivery debuggable
If you can’t trace a notification end-to-end, you’ll waste days on “it didn’t arrive”.
- Generate a notification ID and log it everywhere (backend + app)
- Log provider response codes and message IDs
- Add a “test device” flow in-app to copy token + send a test
- Track open/click events with deep links
Every push must answer: “Why should the user care right now?” If the answer is unclear, it belongs in email, in-app, or nowhere.
Overview
“Push notifications done right” is a blend of product decisions and engineering hygiene. The product part is making messages relevant, timely, and controllable. The engineering part is building a pipeline that’s consistent, secure, and easy to debug.
What this post covers
- Tokens vs topics: how they work, when to use each, and how to avoid common scaling traps
- Payload design: notification vs data payloads, deep links, localization, TTL, collapse keys
- UX patterns: permission timing, notification categories/channels, quiet hours, frequency caps
- Debugging: trace IDs, token invalidation, environment mismatches, and practical test flows
- Operational habits: segmentation rules, rate limits, retries, and cleanup loops
| Goal | How to achieve it | Common failure mode |
|---|---|---|
| High opt-in | Ask after value; explain categories; let users control frequency | Prompt on first launch → “Don’t Allow” forever |
| High relevance | Segment with topics + user preferences; target events not broadcasts | “Send to everyone” repeatedly |
| Reliability | Token refresh, cleanup, TTL/collapse, proper priority and channels | Stale tokens; silent drops; wrong environment |
| Debuggability | Trace IDs, provider responses, structured logs, test devices | “It didn’t arrive” with zero evidence |
Core concepts
Before you implement anything, it helps to have a shared mental model. Push is not “send a message”. Push is “request delivery through a chain of systems”, each with its own constraints: OS policies, provider limits, app state, network availability, and user settings.
Tokens: the device address (and it changes)
A device token (or registration token) identifies an app installation on a specific device. It can rotate (OS/provider decisions), become invalid (app uninstall), or stop being deliverable (user disables notifications). Your system should treat tokens like temporary credentials: store them, refresh them, and remove them when they fail.
Topics: subscription-based broadcast
A topic is a named audience that devices can subscribe to (e.g., price_drops, release_notes).
Topics simplify targeting and reduce backend complexity: instead of managing a million tokens for “people who want release notes”,
you send once to a topic and let the provider fan out.
Use topics when the audience is defined by a user preference or interest. Use tokens when the message is personal, transactional, or needs per-user customization.
Notification payload vs data payload
Most ecosystems support two styles:
- Notification payload: the OS renders UI for you (title/body). Great for straightforward alerts.
- Data payload: the app receives key/value data and decides what to do (render, deep link, fetch). Great for custom UX.
In practice you often send both: a notification for immediate display, plus data for navigation (deep links) and analytics.
Delivery controls: TTL, priority, collapse, and “don’t spam”
These knobs matter more than most people expect:
TTL (time-to-live)
How long a message is valid. A “flash sale” should expire quickly; a “security alert” can live longer.
Collapse key / thread
If you send multiple updates, collapse them into one (e.g., “3 new messages” instead of 3 pushes).
Priority
High priority can wake devices (with caveats). Use sparingly to respect battery and OS policies.
Rate limits & caps
Your best UX feature is often “send less”: daily caps, cooldown windows, and quiet hours.
UX primitives: channels, categories, and preference centers
Users don’t want “notifications” as a single binary switch—they want control. On Android, notification channels are the OS-native way to expose that control. On iOS, categories and app settings play a similar role. Your job is to map product intent to these primitives so users can tune without uninstalling.
Step-by-step
This is a practical build path you can follow in a real app. It starts with the product decisions (what should be pushed) and ends with the engineering reality (token lifecycle, sending, testing, and cleanup).
Step 1 — Define your notification “contract” (what is allowed)
Write this down before you ship anything. It prevents “push creep” where every team uses push as a megaphone.
- Allowed categories: transactional (order updates), reminders, content updates, promotions
- Frequency caps: e.g., max 1 promo/day, max 3 content pushes/day
- Quiet hours: e.g., no promos 21:00–08:00 (local time)
- Success metric: open-to-action conversion, not just open rate
- Fallback: if not urgent, prefer in-app inbox or email
Step 2 — Build a permission flow that earns “Allow”
The OS permission prompt is a one-shot moment. If you prompt too early, users will deny—and you may never get another chance. A good pattern is:
- Pre-prompt: lightweight UI explaining the value (one sentence) + “Not now”.
- Trigger: ask after a clear action (followed a topic, started tracking a shipment, saved a search).
- Preference center: let users pick categories and frequency inside the app.
Users may want notifications before they create an account (price drops, breaking updates, reminders). Keep permission and preferences separate from authentication—and reconcile when the user logs in.
Step 3 — Implement token lifecycle on the client
A reliable system assumes tokens will change. Your app should:
- Fetch the current token on startup (after permission is granted).
- Listen for token refresh and re-register with your backend.
- Subscribe/unsubscribe to topics based on in-app preferences.
- Keep sending idempotent (don’t create duplicate device records).
Example (Android + FCM): register token, handle refresh, and manage topic subscriptions. Replace the backend URL with your own.
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
// Call after notification permission is granted (Android 13+ requires runtime permission)
fun syncPushStateWithBackend(userId: String?, allowMarketing: Boolean) {
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token ->
// Send token + metadata to your backend (idempotent upsert)
CoroutineScope(Dispatchers.IO).launch {
postJson(
url = "https://api.example.com/push/register",
body = mapOf(
"token" to token,
"platform" to "android",
"userId" to userId,
"marketingOptIn" to allowMarketing
)
)
}
// Topic subscriptions should reflect explicit user preferences
if (allowMarketing) {
FirebaseMessaging.getInstance().subscribeToTopic("promos_weekly")
} else {
FirebaseMessaging.getInstance().unsubscribeFromTopic("promos_weekly")
}
}
}
class PushService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// Token rotated: re-sync with backend ASAP
CoroutineScope(Dispatchers.IO).launch {
postJson(
url = "https://api.example.com/push/register",
body = mapOf(
"token" to token,
"platform" to "android"
)
)
}
}
override fun onMessageReceived(message: RemoteMessage) {
// Use message.data for deep links / routing; notification payload may render automatically
val deepLink = message.data["deep_link"]
if (deepLink != null) {
// Optionally cache deep link for when user taps
}
}
}
// Pseudo-implementation: keep it idempotent and retriable on flaky networks
suspend fun postJson(url: String, body: Map<String, Any?>) {
// Use your HTTP client of choice; include auth if needed
}
Step 4 — Design a backend data model that supports targeting
Your backend shouldn’t store “just a token”. Store enough context to target safely and to debug. A minimal device record often includes:
| Field | Why you need it |
|---|---|
| token | The delivery address (can rotate) |
| user_id (nullable) | Link devices to accounts; supports multi-device users |
| platform / app_version | Debug platform-specific issues and staged rollouts |
| locale / timezone | Localization and quiet hours by local time |
| opt_in_flags | User preferences per category (marketing vs transactional) |
| last_seen | Cleanup stale devices; spot “dead” tokens |
Make /push/register an upsert: same token updates metadata; same user can have many tokens (multiple devices).
This prevents duplicated sends and makes cleanup safer.
Step 5 — Build payloads that navigate (and don’t surprise)
The best push is actionable: it opens the right screen and the content matches the title/body. A good payload typically includes:
- Title/body: short, specific, value-forward.
- Deep link: where to send the user on tap.
- Category/channel: so users can control this type.
- TTL + collapse: avoid stale spam and notification floods.
Example message body (FCM-style) combining notification + data + platform hints:
{
"message": {
"topic": "release_notes",
"notification": {
"title": "v2.8 is live: faster sync",
"body": "Offline edits merge better, and conflicts are clearer. Tap to see what changed."
},
"data": {
"deep_link": "unilab://changelog?v=2.8",
"campaign": "release_2_8",
"trace_id": "pn_2026_01_09_1421_9f3c"
},
"android": {
"priority": "HIGH",
"notification": {
"channel_id": "product_updates",
"tag": "release_notes",
"click_action": "OPEN_DEEPLINK"
},
"ttl": "3600s"
},
"apns": {
"headers": {
"apns-priority": "10",
"apns-collapse-id": "release_notes"
},
"payload": {
"aps": {
"category": "PRODUCT_UPDATES",
"thread-id": "release_notes",
"sound": "default"
}
}
}
}
}
Step 6 — Send safely: topic broadcasts and token-targeted messages
Your send pipeline should enforce rules automatically: opt-in, quiet hours, frequency caps, and deduplication. Do not rely on “remembering” these rules in each feature—centralize them in one service.
When to send to a topic
- New content or feature releases users opted into
- Regional alerts (e.g.,
city_bratislava) if users chose location-based updates - Interest-based streams (sports, finance, deals)
When to send to tokens
- Transactional events: “order shipped”, “password changed”, “new login”
- Personalized content: “your saved search has new results”
- Multi-device delivery: send to all user tokens with per-device settings
Example (Node.js): send a message via the Firebase Admin SDK, with cleanup for invalid tokens and a trace ID for debugging.
import admin from "firebase-admin";
admin.initializeApp({
credential: admin.credential.applicationDefault()
});
// Centralized send: enforce opt-in, caps, and quiet hours before calling this.
export async function sendPushToToken({ token, title, body, deepLink, traceId }) {
const message = {
token,
notification: { title, body },
data: {
deep_link: deepLink ?? "",
trace_id: traceId
},
android: {
priority: "high",
ttl: 60 * 60 * 1000, // 1 hour
notification: {
channelId: "product_updates",
tag: "updates"
}
},
apns: {
headers: {
"apns-priority": "10",
"apns-collapse-id": "updates"
},
payload: {
aps: {
category: "PRODUCT_UPDATES",
threadId: "updates",
sound: "default"
}
}
}
};
try {
const messageId = await admin.messaging().send(message);
return { ok: true, messageId };
} catch (err) {
// Handle invalid/expired tokens so you don't retry forever.
// Common strategy: mark token as inactive and stop sending to it.
const code = err?.errorInfo?.code || err?.code || "unknown";
const isInvalid =
code === "messaging/registration-token-not-registered" ||
code === "messaging/invalid-registration-token";
return { ok: false, code, deactivateToken: isInvalid };
}
}
Step 7 — Debug like a pro: “it didn’t arrive” checklist
Delivery is affected by user settings, OS policies, device state, and provider responses. When a push doesn’t arrive, avoid guessing. Work from evidence in this order:
Debug flow
- Is the user opted in? Permission granted + category enabled + app-level preference true.
- Is the token current? Token exists, recently synced, not marked inactive.
- Did the provider accept the send? Log response code + message ID.
- Is the payload valid? Size limits, correct fields, valid channel/category IDs.
- Is the device reachable? Offline, battery saver, focus modes, DND, background restrictions.
- Is this the right environment? Sandbox vs production credentials, bundle IDs, keys.
Add an internal “Push Debug” screen that shows: permission state, token, subscribed topics, last received trace ID, and a button to request a test push. It saves hours during QA and release week.
Step 8 — Measure what matters (and iterate)
Open rate is a weak proxy. Track the user journey: push received → opened → performed the intended action. Then improve by targeting and timing, not by sending more.
Minimum metrics to track
- Opt-in rate (by prompt timing and user segment)
- Delivery errors (invalid tokens, rejected requests)
- Open rate (by category)
- Action conversion (tap → intended screen action)
- Mute/uninstall rate (after campaigns)
High-leverage experiments
- Frequency cap tuning (often improves long-term opt-in)
- Better segmentation (topics + preferences)
- Shorter TTL for time-sensitive pushes
- Collapse keys for “updates” streams
- Localization + timezone-aware sending
Common mistakes
Most push notification failures are predictable. Here are the pitfalls that cause low opt-in, low engagement, and “random” delivery issues—plus concrete fixes.
Mistake 1 — Prompting on first launch
Users haven’t seen value yet, so the rational choice is “Don’t Allow”.
- Fix: delay until a value moment (follow/save/track), and use a one-line pre-prompt.
- Fix: add a preference center so “yes” has boundaries.
Mistake 2 — Using tokens for everything
Backend complexity explodes, and segmentation becomes fragile and expensive.
- Fix: use topics for interest-based audiences; keep tokens for personal/transactional messages.
- Fix: keep topic names stable and user-choice driven.
Mistake 3 — Ignoring token rotation and invalidation
You keep sending to dead tokens and wonder why delivery “drops”.
- Fix: handle token refresh events and upsert device records.
- Fix: deactivate tokens on “not registered/invalid token” provider errors.
Mistake 4 — No trace ID, no logs, no evidence
Debugging becomes superstition: “try again”, “maybe it’s Apple”, “works on my phone”.
- Fix: add a trace ID in payload + logs; store provider message ID.
- Fix: ship a “Push Debug” screen for internal builds.
Mistake 5 — No frequency caps or quiet hours
Short-term clicks, long-term opt-out. Users will protect themselves.
- Fix: implement global caps per category and respect timezone-based quiet hours.
- Fix: collapse multiple updates into one notification.
Mistake 6 — Payload doesn’t match the destination
“Tap to see your results” opens a generic home screen. Users stop trusting pushes.
- Fix: include deep links and validate routing in QA.
- Fix: add guardrails: if deep link is missing, don’t send (or degrade to in-app).
Mixing transactional and marketing pushes in the same channel/category trains users to disable everything. Separate categories (and respect opt-in) so critical messages aren’t punished for promotional noise.
FAQ
Should I use tokens or topics for push notifications?
Use topics for interest-based audiences and tokens for personal or transactional messages. Topics keep your backend simple for broadcasts, while tokens are best for 1:1 content (order updates, account alerts, personalized recommendations).
Why do push notifications sometimes “not arrive” even if the provider accepted the send?
Acceptance is not the same as immediate delivery. Device state (offline, battery saver), user settings (disabled notifications, focus/DND), OS policies, TTL expiry, and environment mismatches can prevent display even if the provider returns success.
What’s the best time to ask for notification permission?
After the user experiences value and expects an update. Trigger the prompt after actions like tracking an order, saving a search, following a topic, or enabling reminders—then offer category controls so “Allow” feels safe.
What should I include in a push payload?
Keep it small and actionable: title/body, a deep link, a category/channel, and delivery controls (TTL/collapse). Add a trace ID for debugging and analytics, and avoid stale promotions by setting an appropriate TTL.
How do I handle invalid or expired tokens?
Deactivate them on provider error and stop retrying. Treat “token not registered/invalid” as a cleanup signal. Also handle token refresh events on the client to re-register automatically.
How do I prevent notification spam as my app grows?
Centralize policy: frequency caps, quiet hours, per-category opt-in, and collapse rules should live in one “notification service” rather than in each feature team’s code. This keeps behavior consistent and protects user trust.
Cheatsheet
A scan-fast checklist for shipping push notifications without annoying users (or yourself).
UX checklist
- Permission prompt happens after a value moment (not first launch)
- Pre-prompt explains benefit in one sentence
- Preference center exists (categories + frequency)
- Quiet hours respected (timezone-aware)
- Frequency caps enforced per category (promo vs transactional)
- Deep link always routes to the promised screen
Engineering checklist
- Token registration is idempotent (upsert)
- Token refresh re-syncs to backend
- Invalid tokens are deactivated on provider error
- Topics used for broad audiences; tokens for personal messages
- TTL set appropriately; collapse used for update streams
- Trace ID included in payload + logs + analytics
Fast defaults (good enough for most apps)
| Message type | TTL | Collapse | Targeting |
|---|---|---|---|
| Transactional (security, orders) | 6–24 hours | Rarely (except “status updates”) | Tokens (all devices for user) |
| Reminders (calendar, habits) | 1–6 hours | Yes (one reminder thread) | Tokens (personalized) |
| Content updates (news, releases) | 15–120 minutes | Yes (release_notes/news) | Topics (opt-in) |
| Promotions (sales) | 15–60 minutes | Yes (promos) | Topics or segments (strict caps) |
Push is a trust channel. You can always send more later, but you can’t easily undo a user who muted you.
Wrap-up
Push notifications done right are not magic—they’re a set of consistent choices: earn permission, target with intent, design payloads that navigate, and build a pipeline you can debug. If you implement token lifecycle + cleanup, use topics for broad opt-in audiences, and enforce caps/quiet hours, you’ll avoid the two classic outcomes: “nobody opts in” and “everybody opts out.”
Next actions (pick one)
- Add a preference center with categories (marketing vs transactional) and map them to channels/categories
- Implement token refresh handling and deactivate invalid tokens on send errors
- Add trace IDs + a “Push Debug” internal screen for fast QA
- Create 2–3 stable topics and subscribe only when users opt in
Want to go deeper on shipping-quality mobile systems? The related posts below cover platform tradeoffs, testing, networking, and CI—perfect companions to a reliable notifications pipeline.
Quiz
Quick self-check (demo). This quiz is auto-generated for mobile / development / push.