COREVANIX
  • About
Let's talk
Mobile app

Implementing push notifications properly on mobile: 10 mistakes to avoid

Push notifications are a top-three retention tool, or a top-three reason to uninstall. Ten common mistakes and the correct implementation, with code.

COCorevanix Kft.2 April 202615 min read
Implementing push notifications properly on mobile: 10 mistakes to avoid

Push lifecycle

  1. 01

    Soft ask

    Delay the opt-in until the first meaningful action. An in-app explainer lifts opt-in rate by 20-30 pp.

  2. 02

    Token register

    After login, store user_id + device_id + push_token in Postgres. Refresh on every successful sign-in.

  3. 03

    Targeted send

    Cap frequency at three per week, send within 09:00-21:00 local time, and personalise on user behaviour.

  4. 04

    Analytics + iterate

    Delivery 95%+, open rate 5-15% for promo and 30-50% for transactional. Cohort the opt-out trend weekly.

Push notifications are one of the most powerful tools for user retention — and mishandled, they're the number one reason for uninstalls. The statistics are brutally clear: notifications that are too frequent or irrelevant rank among the top three reasons users revoke app permissions, on both iOS and Android (based on Pushwoosh / Airship 2024 reports).

This article covers 10 mistakes that nearly every first-time mobile app team makes, along with the correct implementation. With concrete code for both platforms. Examples are in Swift 6, Kotlin (Jetpack Compose) and React Native (Expo Notifications).

Why this topic matters

A few statistics from 2025 app-industry reports:

  • Push-permission opt-in rate on iOS (since the 2018 IDFA restrictions): 50-60% baseline, 70-80% with a well-designed soft ask.
  • Push-permission revoke / uninstall rate under overuse: 20-30% within 3 months.
  • Open rate on well-segmented push: 25-40%.
  • Open rate on generic push: 2-5%.
  • Conversion impact of personalized push: 4-8%, versus under 1% for generic.

The difference is dramatic. The 10 mistakes below are what stand between a 2-5% open rate and a 25-40% one.

1. Requesting permission in the first few seconds

Mistake: The user opens the app, and the very first screen shows an "Allow push notifications?" prompt. On iOS you can only ask this once — if the answer is "No," you can never trigger the system prompt programmatically again.

The user has no idea why they should grant permission — they haven't seen anything of the app yet. The decline rate at this point runs 60-70%.

Correct approach: Delay the prompt until the first meaningful user action (e.g., the first successful login, or the end of onboarding). Use a soft ask beforehand: an in-app screen explaining when you'll message the user and what value it provides.

iOS Swift implementation

import UserNotifications
import UIKit

final class PushPermissionManager {
    static let shared = PushPermissionManager()

    func showSoftAskThenRequest(from vc: UIViewController) {
        let alert = UIAlertController(
            title: "Notifications",
            message: "Allow us to notify you when a new offer becomes available? Maximum 2-3 messages a week.",
            preferredStyle: .alert,
        )
        alert.addAction(.init(title: "Not now", style: .cancel))
        alert.addAction(.init(title: "Allow", style: .default) { _ in
            self.requestPermission()
        })
        vc.present(alert, animated: true)
    }

    private func requestPermission() {
        UNUserNotificationCenter.current()
            .requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
                if granted {
                    DispatchQueue.main.async {
                        UIApplication.shared.registerForRemoteNotifications()
                    }
                }
            }
    }
}

Android Kotlin implementation

Since Android 13 (API 33), push permission has become a runtime permission, similar to iOS:

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts

@Composable
fun PushPermissionScreen(onGranted: () -> Unit) {
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
    ) { granted ->
        if (granted) onGranted()
    }

    Column { /* ... soft ask UI ... */ }

    Button(onClick = {
        launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
    }) {
        Text("Allow")
    }
}

React Native (Expo)

import * as Notifications from 'expo-notifications';

async function requestPushPermission() {
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;
  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }
  return finalStatus === 'granted';
}

The two-step soft-ask-then-real-prompt flow raises opt-in rate from 50-60% to 70-80%.

2. Token registration doesn't run at login

Mistake: You register the push token with the backend only on first app launch. If the user logs out and signs back in with a different account, notifications keep arriving for the old account.

Correct approach: Refresh the token on every successful login. The backend stores (user_id, device_id, push_token, platform, last_seen).

// Lifecycle: login
async function onUserLogin(userId: string) {
  const token = await Notifications.getExpoPushTokenAsync();
  await api.post('/push/register', {
    user_id: userId,
    device_id: await getDeviceId(),
    push_token: token.data,
    platform: Platform.OS,
  });
}

// Lifecycle: logout
async function onUserLogout(userId: string) {
  await api.post('/push/unregister', {
    user_id: userId,
    device_id: await getDeviceId(),
  });
}

Backend schema

CREATE TABLE push_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    device_id TEXT NOT NULL,
    push_token TEXT NOT NULL,
    platform TEXT CHECK (platform IN ('ios', 'android', 'web')),
    created_at TIMESTAMP DEFAULT NOW(),
    last_seen TIMESTAMP DEFAULT NOW(),
    UNIQUE(user_id, device_id)
);

CREATE INDEX idx_push_user ON push_tokens(user_id);
CREATE INDEX idx_push_token ON push_tokens(push_token);

Store device_id in MMKV (RN) or SharedPreferences (Android) as a per-installation unique identifier.

3. Generic, non-personalized messages

Mistake: "New promotion! Check it out now." → the user mutes the app after three days.

Generic broadcast push drags the open rate down to 2-5%. That means out of 1,000 users, only 50 open the notification — the rest either ignore it or get annoyed.

Correct approach: Personalized, contextual messages based on user behavior.

Generic:        "New promotion! Check it out now."      Open rate: 3%
Personalized:   "The ABC-123 part you were              
                 browsing is now 15% off for             
                 the next 24 hours."                     Open rate: 28%

Segmentation strategy

Segment Trigger Message style
New user (< 7 days) Onboarding step missing "Finish your profile in 2 minutes — here's what you get in return..."
Active user (3+ sessions/week) New feature release "New feature: X. Try it now."
Re-engagement (inactive 14+ days) Personalized comeback "20% off on the ABC-123 product, specifically for you."
Cart abandonment 1, 24, 72 hours in cart "You have 3 items in your cart — continue checkout."

Backend implementation (Node.js / TypeScript)

type PushPayload = {
  user_id: string;
  template: 'cart_abandon' | 'new_feature' | 'personalized_promo';
  context: Record<string, string>;
};

async function sendPersonalizedPush(payload: PushPayload) {
  const user = await db.users.findById(payload.user_id);
  const template = TEMPLATES[payload.template];
  const message = template(user, payload.context);

  const tokens = await db.pushTokens.findByUserId(payload.user_id);
  for (const t of tokens) {
    await pushProvider.send(t.push_token, message);
  }
}

4. Time zone not handled

Mistake: The European server runs on UTC, and the user's iPhone receives a "good morning" greeting at 23:00.

"Good morning! Here are today's deals" is not exactly welcome at 11:35 PM. The user uninstalls the next morning.

Correct approach: Store the user's time zone. Send-time optimization: send based on when that specific user's history shows they actually open push notifications.

// Backend: send window
async function sendInTimeWindow(userId: string, payload: any) {
  const user = await db.users.findById(userId);
  const userLocalHour = getUserLocalHour(user.timezone);
  
  if (userLocalHour < 9 || userLocalHour > 21) {
    const nextSendTime = scheduleForNextMorning(user.timezone);
    await scheduler.scheduleAt(nextSendTime, () => {
      pushProvider.send(userId, payload);
    });
    return { status: 'scheduled', sendAt: nextSendTime };
  }
  
  return pushProvider.send(userId, payload);
}

Time-zone storage

ALTER TABLE users ADD COLUMN timezone TEXT DEFAULT 'Europe/Budapest';

-- App-side: refresh on every launch
UPDATE users SET timezone = $1 WHERE id = $2;

Send-time optimization (advanced)

Analyzing a given user's push-open history lets you compute their best send time:

SELECT
    user_id,
    EXTRACT(HOUR FROM (opened_at AT TIME ZONE timezone)) AS open_hour,
    COUNT(*) AS open_count
FROM push_events
WHERE event_type = 'opened'
GROUP BY user_id, EXTRACT(HOUR FROM (opened_at AT TIME ZONE timezone))
ORDER BY open_count DESC
LIMIT 1;

A one-hour send-window optimization delivers +30-50% open rate compared to an "every user, same time" strategy.

5. Deep links don't work

Mistake: The user taps the notification ("Product X is now available"), and the app opens to the home screen instead of the product page.

After two or three such misfires, the user stops tapping notifications altogether. "It never shows what it promises anyway."

Correct approach: A deep link in every notification. iOS: Universal Links, Android: App Links. The payload includes a route field, and the app navigates to it on launch.

Payload structure

{
  "to": "ExponentPushToken[xxx]",
  "notification": {
    "title": "The ABC-123 product is now available",
    "body": "Tap for details — 15% off",
    "sound": "default",
    "badge": 1
  },
  "data": {
    "route": "product/abc-123",
    "campaign_id": "promo_q2_2026",
    "user_segment": "active_cart_abandon"
  }
}

RN deep link handler

import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';

useEffect(() => {
  const subscription = Notifications.addNotificationResponseReceivedListener(
    (response) => {
      const route = response.notification.request.content.data?.route;
      const campaignId = response.notification.request.content.data?.campaign_id;
      
      // Track open
      analytics.track('push_opened', { campaign_id: campaignId });
      
      if (route) {
        router.push(`/${route}`);
      }
    },
  );
  return () => subscription.remove();
}, []);

iOS Universal Links setup

// In AppDelegate
func application(_ application: UIApplication,
                continue userActivity: NSUserActivity,
                restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return false }
    
    return navigationCoordinator.handle(url: url)
}

6. Push spam — 10+ messages a week

Mistake: The marketing team sends 2-3 push notifications a day to every user. After a month, the uninstall rate exceeds 25%.

The "more push, more revenue" mindset is demonstrably wrong by 2026. User uninstalls cause more damage in the long run than any short-term click-through they generate.

Correct approach: Frequency cap: max 3 push notifications per week per user. Everything else goes to email, an in-app message, or nowhere.

Frequency-cap implementation

async function canSendPush(userId: string): Promise<boolean> {
  const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  const sentCount = await db.pushEvents.count({
    user_id: userId,
    event_type: 'sent',
    sent_at: { gte: lastWeek },
  });
  return sentCount < 3;
}

Priority-based override

Sometimes you need a high-priority push that overrides the frequency cap:

type Priority = 'transactional' | 'promotional';

async function sendWithFrequencyCap(
  userId: string,
  payload: PushPayload,
  priority: Priority = 'promotional',
) {
  if (priority === 'transactional') {
    return pushProvider.send(userId, payload); // bypass cap
  }
  
  const canSend = await canSendPush(userId);
  if (!canSend) {
    await db.queues.add('email', { user_id: userId, payload });
    return { status: 'fallback_to_email' };
  }
  
  return pushProvider.send(userId, payload);
}

An "order shipped" push is transactional → it always goes out. A "new product" push is promotional → subject to the frequency cap.

Tip: Give users a frequency control in settings. "Normal" (default), "weekly digest" and "transactional only" options give power users fine-grained control while protecting your overall opt-in rate.

7. Broken opt-out handling

Mistake: The user disables notifications at the OS level. The app keeps hammering the backend with token-refresh requests, and the backend keeps sending push notifications that never arrive — silently lost.

Correct approach: Check on every launch: UNUserNotificationCenter.current().getNotificationSettings() on iOS, NotificationManagerCompat.from(context).areNotificationsEnabled() on Android. If disabled, flag the user on the backend: push_enabled = false. Let the email flow take over.

// iOS
UNUserNotificationCenter.current().getNotificationSettings { settings in
    let isEnabled = settings.authorizationStatus == .authorized
    api.updatePushStatus(enabled: isEnabled)
}
// Android
val isEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
api.updatePushStatus(enabled = isEnabled)

Email-fallback flow

If push is disabled:

  1. Mark user.push_enabled = false in the DB
  2. Trigger the marketing email flow for the same campaign (if the user opted in to email)
  3. On app launch, offer a return point to the soft ask (re-enabled from settings)

8. Notification text that's too long

Mistake: A 200-character message that gets truncated in the notification panel: "New promotion in the product category you were browsing last we..."

The iOS notification banner shows roughly 80-100 characters, Android around 80 (device-dependent). Truncated text has no impact.

Correct approach: Keep the title under 30 characters, the body under 80. Every mobile OS truncates at a different length, so design for the shortest.

Wrong:
  Title: "New promotion in the product category you browsed last week!"
  Body: "The ABC-123 part you looked at last week is now available with a 15% discount for the next 24 hours. Hurry, stock is limited!"

Correct:
  Title: "ABC-123 — 15% off"  (18 char)
  Body: "15% off your part, 24 hours only"  (33 char)

A/B testing short vs long

Variant Title length Open rate Conversion
Long-form 60+ char 4.2% 0.8%
Short-form 25 char 12.5% 2.1%

The short form delivers a 3x better open rate in the measured average case.

9. Image / rich-notification overload

Mistake: You attach a 1MB image to every push. The iOS notification service extension times out (30s), and the image never appears.

The user sees the notification, but with "attachment failed" or nothing at all. Your custom UI ends up sabotaging its own notification format.

Correct approach: Cap image attachments at 300KB, served from a CDN, well optimized. Use them only in relevant contexts (a product image, an order image). 80% of notifications should ship with no image at all.

iOS Notification Service Extension

// NotificationServiceExtension/NotificationService.swift
import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    override func didReceive(_ request: UNNotificationRequest,
                            withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        guard let bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent,
              let imageUrlString = request.content.userInfo["image_url"] as? String,
              let imageUrl = URL(string: imageUrlString) else {
            contentHandler(request.content)
            return
        }
        
        URLSession.shared.downloadTask(with: imageUrl) { localUrl, _, _ in
            guard let localUrl = localUrl else {
                contentHandler(bestAttempt)
                return
            }
            
            if let attachment = try? UNNotificationAttachment(
                identifier: "image",
                url: localUrl,
                options: nil,
            ) {
                bestAttempt.attachments = [attachment]
            }
            contentHandler(bestAttempt)
        }.resume()
    }
}

Server-side image best practices

  • Max 300KB
  • WebP or PNG, avoid JPEG (transparency)
  • Served from a CDN, edge-cached
  • Pre-resized (1024x512 works well for both iOS and Android)

10. Missing analytics

Mistake: You send the push, and you have no idea who opens it, who ignores it, who mutes it.

Push-campaign optimization only works if you measure it. Without measurement, the marketing team sends "by feel."

Correct approach: OneSignal or Firebase Cloud Messaging analytics. KPIs to track:

  • Delivery rate (delivered vs. sent) — target: 95%+
  • Open rate (opened vs. delivered) — target: 5-15% for promotional, 30-50% for transactional
  • Conversion rate (action taken after tapping) — target: 1-5%
  • Opt-out rate (push permission revoked) — target: under 5% / month

Event-tracking schema

CREATE TABLE push_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    campaign_id TEXT,
    event_type TEXT CHECK (event_type IN ('sent', 'delivered', 'opened', 'converted', 'dismissed')),
    timestamp TIMESTAMP DEFAULT NOW(),
    metadata JSONB
);

CREATE INDEX idx_push_events_user ON push_events(user_id, timestamp DESC);
CREATE INDEX idx_push_events_campaign ON push_events(campaign_id, event_type);

Cohort analysis

Track push sensitivity by weekly user cohort. The "opted out within 7 days" cohort is a signal of oversending.

WITH user_cohorts AS (
  SELECT
    user_id,
    DATE_TRUNC('week', created_at) AS cohort_week
  FROM users
),
opt_outs AS (
  SELECT
    user_id,
    MAX(timestamp) AS opt_out_time
  FROM push_events
  WHERE event_type = 'dismissed'
    AND metadata->>'reason' = 'system_disabled'
  GROUP BY user_id
)
SELECT
  uc.cohort_week,
  COUNT(uc.user_id) AS total_users,
  COUNT(oo.user_id) AS opted_out,
  ROUND(100.0 * COUNT(oo.user_id) / COUNT(uc.user_id), 2) AS opt_out_rate
FROM user_cohorts uc
LEFT JOIN opt_outs oo ON uc.user_id = oo.user_id
GROUP BY uc.cohort_week
ORDER BY uc.cohort_week;

The opt-out rate trend is a critical metric to watch.

Choosing a provider, briefly

Firebase Cloud Messaging (FCM)

Free, scalable, works on both iOS and Android. The default choice for 90% of projects.

Pros:

  • Free, unlimited volume
  • One SDK for both platforms
  • Topic-based subscription support
  • Analytics integration

Cons:

  • No native UI for audience segmentation
  • A/B testing is limited
  • Lacks a marketer-friendly UI

OneSignal

Managed UI, A/B testing, segmentation. Free tier up to 10k users, paid beyond that. Suited to marketing-focused teams.

Pros:

  • Excellent marketer UI
  • Built-in segmentation, A/B testing
  • Cross-channel (push, email, SMS, in-app)
  • Affordable pricing

Cons:

  • Vendor lock-in
  • $99/month in the 50k-100k user range

Apple Push Notification Service (APNs) direct

Native iOS, for when the FCM layer isn't warranted.

Pros:

  • Lowest latency
  • Direct Apple integration
  • No third-party fee

Cons:

  • iOS only
  • Self-managed scaling

Decision matrix

Use case Provider
MVP, free FCM
Marketing-heavy team OneSignal
Enterprise, multi-channel OneSignal or Airship
iOS-only, low volume APNs direct
Cross-platform B2C, high volume FCM + custom analytics

A/B testing push messages

A/B testing is essential for push messages. Sending two variants simultaneously yields a significant result within 1-2 weeks.

async function sendABTestPush(userIds: string[], variantA: Payload, variantB: Payload) {
  const shuffled = userIds.sort(() => Math.random() - 0.5);
  const half = Math.floor(shuffled.length / 2);
  
  await Promise.all([
    ...shuffled.slice(0, half).map(uid => sendWithTracking(uid, variantA, 'variant_a')),
    ...shuffled.slice(half).map(uid => sendWithTracking(uid, variantB, 'variant_b')),
  ]);
}

What to measure:

  • Delivery rate (usually identical)
  • Open rate (the primary metric)
  • Conversion rate (the downstream effect)

The winning variant becomes the new default after 2-3 days.

Official documentation and further reading

  • Apple Notification Programming Guide — APNs official
  • Firebase Cloud Messaging — FCM official
  • OneSignal documentation — OneSignal guide
  • Android Notification Channels — channels deep dive
  • Expo Notifications — RN/Expo guide

Related articles from us: App Store & Play Store deployment 2026 — release pipeline. React Native vs. native, 2026 — platform selection. Mobile app GDPR compliance — consent management, which also affects push permission.

Closing thoughts

Push notifications are a powerful tool, but one that demands care. Each of the 10 mistakes above has genuinely killed real projects. A good push flow is restrained, time-zone-aware, and deep-links correctly on tap.

Getting push strategy right is hard the first time; by the third or fourth iteration it becomes routine. The first three months are a learning phase — you measure open rate and opt-out rate, and iteratively tune frequency and segmentation.

If you're planning a mobile app project, let's talk through your push and retention strategy — it's often not the feature set that's missing, but fine-tuning of the user flow. Setting up the first 90 days as a monitoring phase is part of the deliverable.

Tags
  • #Push Notifications
  • #iOS
  • #Android
  • #Firebase
  • #OneSignal
  • #UX
ShareLinkedInX

About the author

CO

Corevanix Kft.

Technology partner

Budapest-based technology partner — SAP/ERP integration, web development, AI automation and mobile app development. We work inside the client’s own environment, and the delivered code belongs entirely to the client.

Planning a project?

Let's talk in a 30-minute call.

Book a callSend an email

Related articles

  • React Native vs native (Swift/Kotlin) in 2026: when to choose which
    Mobile app

    React Native vs native (Swift/Kotlin) in 2026: when to choose which

    Performance benchmarks, developer experience, ecosystem and five use-case recommendations. React Native or native: decide on technical grounds, not habit.

    15 April 202612 min read
    Read more
  • App Store and Play Store deployment in 2026: the complete guide for new developers
    Mobile app

    App Store and Play Store deployment in 2026: the complete guide for new developers

    A step-by-step guide to iOS App Store and Google Play Store deployment: developer accounts, certificates, the review process and Fastlane automation.

    8 April 202614 min read
    Read more
  • Mobile app GDPR compliance in 2026: what every developer needs to know
    Mobile app

    Mobile app GDPR compliance in 2026: what every developer needs to know

    GDPR basics on mobile, consent management, IDFA / GAID handling, analytics tools and an audit checklist: a concise summary of the 2026 essentials.

    25 March 202612 min read
    Read more
Where do we start?

Where do we start?

  • I'm building a new product.

    Web / app development
  • I have an existing system.

    SAP / ERP integration
  • I want to automate a process.

    AI automation
  • I just want advice.

    Discovery call

Services

  • Enterprise systems
  • Web development
  • AI automation
  • Mobile app development

Tech Stack

  • Web
  • Mobile
  • SAP / ERP
  • AI platform

Company

  • About
  • Case studies
  • Blog
  • Contact

Legal

  • Privacy policy
  • Legal notice
  • Cookie policy
COREVANIX

Corevanix Kft. is a Budapest-based technology partner: SAP/ERP integration, web development, AI automation and mobile app development for companies in Hungary and the EU.

© 2026 Corevanix Kft. All rights reserved.

info@corevanix.com

Headquarters: Budapest, Hungary