
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.
Push notifications are a top-three retention tool, or a top-three reason to uninstall. Ten common mistakes and the correct implementation, with code.

Push lifecycle
Delay the opt-in until the first meaningful action. An in-app explainer lifts opt-in rate by 20-30 pp.
After login, store user_id + device_id + push_token in Postgres. Refresh on every successful sign-in.
Cap frequency at three per week, send within 09:00-21:00 local time, and personalise on user behaviour.
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).
A few statistics from 2025 app-industry reports:
The difference is dramatic. The 10 mistakes below are what stand between a 2-5% open rate and a 25-40% one.
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.
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()
}
}
}
}
}
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")
}
}
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%.
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(),
});
}
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.
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%
| 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." |
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);
}
}
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);
}
ALTER TABLE users ADD COLUMN timezone TEXT DEFAULT 'Europe/Budapest';
-- App-side: refresh on every launch
UPDATE users SET timezone = $1 WHERE id = $2;
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.
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.
{
"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"
}
}
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();
}, []);
// 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)
}
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.
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;
}
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.
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)
If push is disabled:
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)
| 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.
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.
// 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()
}
}
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:
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);
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.
Free, scalable, works on both iOS and Android. The default choice for 90% of projects.
Pros:
Cons:
Managed UI, A/B testing, segmentation. Free tier up to 10k users, paid beyond that. Suited to marketing-focused teams.
Pros:
Cons:
Native iOS, for when the FCM layer isn't warranted.
Pros:
Cons:
| 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 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:
The winning variant becomes the new default after 2-3 days.
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.
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.
About the author
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.

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

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

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