The first thing I quietly lost after shipping a Rork-built app was push permission. I had wired it so the OS dialog — "'App' would like to send you notifications" — appeared the instant the app opened. Faced with that on first launch, most users tapped "Don't Allow" without a second thought.
The nasty part: on iOS, once someone taps "Don't Allow," your app can never bring that dialog back. Even if the value of notifications becomes obvious later, the user has to dig into the Settings app and flip the switch themselves. Almost nobody does. So if you get that single first ask wrong, you lose the notification channel to that user permanently. Running wallpaper and relaxation apps for years, I learned that simply not leaving that "first ask" up to the OS moves the opt-in rate more than anything else. Here's the design.
Why "Right at Launch" Wastes the Most
The OS push dialog comes with two constraints you can't touch. You can't change its wording, and it's a binary choice — "Allow" or "Don't Allow" — where the second answer ends the conversation for good.
First launch is the moment when the user hasn't experienced anything in your app yet. Asking "may I send notifications?" before they've felt any value gives them nothing to decide with. When people hesitate, they pick the option that feels safer: "Don't Allow." That isn't users being cold — it's the natural response to a binary demand made without any context.
This is where pre-permission priming helps. Before the OS dialog, you show your own screen that explains, just once, why notifications are useful, and you only forward people to the OS dialog after they've thought "yeah, I might want that." Your own screen can appear as many times as you like, with whatever copy you choose. Show the OS dialog only to people who warmed up here, and you dramatically cut the instant-death "Don't Allow" taps.
The Core Idea: Demote the OS Dialog to a Final Confirmation
The trick is to flip the framing. Instead of treating the OS dialog as the first gate, position it as the last confirmation for someone who has already made up their mind.
The flow looks like this. First, your own priming screen makes the case. Anyone who taps "Later" there does not see the OS dialog (showing it would spend your one precious shot). Only people who tap "Turn on notifications" get the OS dialog — and since they're already on board by then, it sails through.
The point I most want to land here is this: splitting it into two steps isn't a numbers trick for inflating opt-in rates. The real value is that you give the user's freedom to decline a safe place to land — the "Later" button on your own screen. A decline at the OS dialog can never be retried; a "Later" on your screen can be re-asked when a better context comes along. Moving the decline to a recoverable place is the heart of the design.
Implementing It in Expo
Rork generates apps on Expo (React Native) under the hood. Push permission is handled by expo-notifications. The first rule: only call the permission request from the "Turn on notifications" button on your own screen.
The helper below checks the current permission state and shows the OS dialog only if we've never asked. For people who are already denied, it skips the dialog and signals that we need to route them to Settings instead.
import * as Notifications from 'expo-notifications';
import { Linking } from 'react-native';
type PrimeResult = 'granted' | 'denied' | 'needs-settings';
// Call this when "Turn on notifications" is tapped on your priming screen
export async function requestPushAfterPriming(): Promise<PrimeResult> {
const current = await Notifications.getPermissionsAsync();
// Already granted — do nothing
if (current.status === 'granted') {
return 'granted';
}
// On iOS, once denied, requestPermissionsAsync won't show a dialog —
// it returns denied immediately. Routing to Settings is the only path.
if (current.status === 'denied' && !current.canAskAgain) {
return 'needs-settings';
}
// Only show the OS dialog while still undetermined
const result = await Notifications.requestPermissionsAsync();
return result.status === 'granted' ? 'granted' : 'denied';
}
// Open the Settings app (for the needs-settings case)
export function openNotificationSettings() {
Linking.openSettings();
}The crucial bit is the canAskAgain check. On iOS, once a user has declined, status becomes denied and canAskAgain becomes false, and calling requestPermissionsAsync() shows nothing. If you don't know this and just write "not granted, so let's ask again" loops, you fall into a silent failure: the user sees nothing while your code keeps getting denied on every call. I missed this at first and spent a while puzzled over why my opt-in rate wouldn't budge.
The priming screen that calls it can be as simple as a value statement plus two buttons, "Turn on notifications" and "Later":
import { View, Text, Pressable } from 'react-native';
import { requestPushAfterPriming } from './push';
export function PushPrimingScreen({ onDone }: { onDone: () => void }) {
const handleAllow = async () => {
const result = await requestPushAfterPriming();
// Whether granted or denied, continue the normal flow from here
onDone();
};
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Text style={{ fontSize: 22, fontWeight: '700', marginBottom: 12 }}>
We'll let you know when new wallpapers arrive
</Text>
<Text style={{ fontSize: 15, lineHeight: 22, color: '#555', marginBottom: 32 }}>
A few times a week, just the new picks our editors love. You can change the frequency anytime in settings.
</Text>
<Pressable onPress={handleAllow} style={{ backgroundColor: '#111', padding: 16, borderRadius: 12, alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '600' }}>Turn on notifications</Text>
</Pressable>
<Pressable onPress={onDone} style={{ padding: 16, alignItems: 'center', marginTop: 8 }}>
<Text style={{ color: '#888' }}>Later</Text>
</Pressable>
</View>
);
}Notice that tapping "Later" never calls requestPushAfterPriming. Because you don't fire the OS dialog here, you keep the chance to try again another day.
When to Ask — Timing Beats Copy
Before you polish the wording, fix the timing. In my experience, the ask lands best not at launch but right after the user has had one good experience in the app.
For a wallpaper app, that's the moment they've actually finished setting their first image as the device wallpaper. For a relaxation app, it's right after they've finished their first session and feel good. In other words, you aim for the moment when the value of notifications connects naturally to what just happened: "when a new piece arrives, you can have this experience again." That bridge forms on its own in the user's head.
The three moments to avoid: right at launch, right after a paywall, and right after an error. Launch has no context, post-paywall feels like "another demand," and post-error simply leaves a bad taste.
Recovering After a Decline
For people who tapped "Later" on your own screen, re-ask a fixed number of times, well spaced out. My rule of thumb is up to three times, with plenty of gap between each. Instead of repeating the same screen, anchor each ask to something that actually happened ("a sequel to the piece you saved was just published"); that makes it feel less pushy.
For people who reached denied at the OS level, place a single, unobtrusive "Turn on notifications" entry point somewhere in the app, and let it open the Settings app via Linking.openSettings(). The important thing is not to nag. Repeatedly pushing someone who already declined at the OS level only erodes the experience for little return — that's what years of operating these apps have taught me.
Done well, notifications become a quiet ally for retention; done wrong, they're a fragile channel where one bad first ask ends the relationship. Demote the OS dialog to a final confirmation, and keep the freedom to decline inside your own screen. That single bit of care should noticeably cut what you lose. I hope it helps anyone stuck at the same spot.