Most apps ask for push notification permission on the first screen the user sees, before they've done anything worth being notified about. On iOS, that single dialog is a one-shot deal — decline it and the only way back is a trip to Settings that almost nobody makes. We've watched opt-in rates on client apps swing from under 30% to over 70% by changing nothing except when the OS prompt fires. The copy, the icon, the app itself stayed the same.
If you're building or rebuilding a mobile app's onboarding flow, permission timing deserves as much design attention as the signup form.
Why the native prompt is a one-shot resource
iOS shows the system permission dialog exactly once per app install. There's no "ask again later" — if the user taps Don't Allow, your only path back is Linking.openSettings()\ and a user willing to dig through iOS Settings > Notifications > Your App. Android has historically been more forgiving (pre-13, permission was implicit; 13+ requires an explicit runtime request, but re-prompting is still possible under some conditions), which means teams that build permission flows against Android behavior first often get burned when they ship to iOS.
Treat the native prompt as a resource you spend once. Everything before it should exist to make that one shot count.
The soft-ask pattern
The fix is a pre-permission screen you control entirely — a normal in-app UI element, not a system dialog — that explains the value of notifications before the OS prompt appears:
- "Get notified when your order ships" with a bell icon and two buttons, Enable Notifications and Not Now
- Only tapping Enable Notifications triggers the real OS permission request
- Tapping Not Now dismisses without touching the OS-level state at all, so the real prompt is still available later
This matters because a soft-ask rejection costs nothing — you can show it again next session, after a different action, with different copy. A hard-ask (OS) rejection on iOS is close to permanent.
\`tsx
import * as Notifications from 'expo-notifications';
async function requestPushPermission() {
const { status: existing } = await Notifications.getPermissionsAsync();
if (existing === 'granted') return true;
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
// Only call requestPushPermission() after the user taps
// "Enable Notifications" on your own in-app screen.
`\
Timing: contextual beats chronological
"Day 3" or "after onboarding" is chronological timing — it's easy to implement and it's why most apps still ask at launch, on a fixed step. Contextual timing ties the ask to a moment where the value is obvious:
- E-commerce: after the first order is placed, not before ("track your delivery")
- SaaS/dashboard apps: after the user creates the first thing worth monitoring (a project, an alert, a report)
- Social/messaging: after the first message is sent or received, not on account creation
- Booking/scheduling: right after a booking is confirmed ("we'll remind you before it starts")
If your app doesn't have an obvious "moment," don't fabricate one — a generic ask on session 2 or 3 still outperforms session 1, because the user has at least formed a first impression of the product.
Platform differences that break naive implementations
-
iOS: one shot per install (until the user manually deletes and reinstalls the app, which resets permission state). Provisional authorization (
provisional: true\in the request options) delivers notifications quietly to Notification Center without a prompt at all — useful for low-stakes, high-volume notification types where you'd rather earn trust than ask upfront. -
Android 13+:
POST_NOTIFICATIONS\is a runtime permission like camera or location, requested the same way. Below Android 13, notifications are granted by default at install, so testing only on newer Android devices can hide bugs that show up for a real chunk of your Android install base. -
Background/foreground state: some permission APIs behave differently depending on whether the request happens immediately on mount vs. after a
useEffect\delay — test the actual timing you'll ship, not just the API call in isolation.
This is one of the areas where React Native vs. Flutter matters less than people expect — both wrap the same underlying OS permission model, and both will burn you the same way if you ask too early.
Instrument it before you optimize it
You cannot tune permission timing without data. Track, at minimum:
- Soft-ask shown → Soft-ask accepted (your in-app funnel)
- Soft-ask accepted → OS permission granted (how much the OS prompt itself costs you)
- Time-to-first-notification-received (a proxy for whether the notification is actually useful once granted)
Without these events split out, "opt-in rate" as a single number hides whether the drop-off is your copy, your timing, or the OS prompt itself — three very different fixes.
A simple checklist
- Never trigger the OS permission dialog on first launch or from a generic onboarding step
- Build a soft-ask screen you fully control, with copy tied to a concrete benefit
- Trigger the soft-ask after a contextual action, not a fixed day/step count
- Handle iOS's one-shot behavior explicitly — treat rejection as (near) final, and offer a Settings deep link post-decline instead of re-asking
- Test on Android 13+ devices specifically; don't assume pre-13 behavior generalizes
- Instrument each funnel step separately before changing anything
This kind of detail is easy to skip when you're racing toward a launch date, but it's cheap to build correctly the first time and expensive to retrofit once your install base has already seen — and declined — the wrong prompt. If you're scoping a new mobile build or auditing an existing one's performance and onboarding, let's talk.
Originally published on the Doktouri Agency blog.
Top comments (0)