DEV Community

Famitha M A
Famitha M A

Posted on

Delayed Push Permissions in React Native: Why the Third-Session Prompt Wins

TL;DR

  • Asking for push permission on first launch is the single most common way apps burn their one clean shot at the iOS prompt
  • Waiting until the third session roughly doubles your odds, because the user has now chosen to come back twice
  • Session counting is a five-line AsyncStorage pattern
  • Better still: trigger on a habit moment (first save, first completed workout, first message) instead of a raw session count
  • Skip the custom "pre-permission" explainer modal unless you have data proving it helps. It often does not
  • If you already shipped the install-time prompt, there is a recovery path via Linking.openSettings()

The default cap

On iOS you get one native permission prompt. Decline it and the OS will never show it again for your app. The user has to dig through Settings to reverse it, which almost nobody does.

So the question is not "how do I ask." It is "when does the user have a reason to say yes."

At install, they don't. They opened your app forty seconds ago. They have no idea what your notifications contain, whether they are useful, or how often they will fire. Blind install-time prompts get rejected constantly, and every rejection is permanent. You are spending an unrecoverable resource at the moment of lowest trust.

What "the third session" actually means

Not "day three." Not "72 hours after install." Session three: the third distinct time the user opens the app. By then they have voluntarily returned twice. They have context. The ask is no longer coming from a stranger.

The mechanic is a counter that increments once per cold start:

import AsyncStorage from '@react-native-async-storage/async-storage';

const SESSION_KEY = 'session_count';

export async function bumpSession() {
  const raw = await AsyncStorage.getItem(SESSION_KEY);
  const count = (parseInt(raw, 10) || 0) + 1;
  await AsyncStorage.setItem(SESSION_KEY, String(count));
  return count;
}
Enter fullscreen mode Exit fullscreen mode

Call it once from your root component:

useEffect(() => {
  bumpSession().then(setSessionCount);
}, []);
Enter fullscreen mode Exit fullscreen mode

Then gate the prompt:

import * as Notifications from 'expo-notifications';

async function maybeAskForPush(sessionCount) {
  if (sessionCount < 3) return;

  const { status } = await Notifications.getPermissionsAsync();
  if (status !== 'undetermined') return; // already asked, never re-burn

  await Notifications.requestPermissionsAsync();
}
Enter fullscreen mode Exit fullscreen mode

The undetermined check matters. It makes the function safe to call on every launch without ever re-triggering anything.

Habit moments beat session counts

Session three is the floor, not the ceiling. A raw counter still fires the prompt at an arbitrary moment. The stronger pattern is tying the ask to the first action that notifications will actually serve:

  • A task app asks right after the user sets their first due date
  • A fitness app asks after the first completed workout
  • A chat app asks when the user sends their first message

At that moment the value proposition is self-evident. "Want a reminder when this is due?" needs no explanation. If your app has a moment like that, use it as the trigger and keep the session count as a fallback for users who never hit it:

async function askAtHabitMoment() {
  const { status } = await Notifications.getPermissionsAsync();
  if (status === 'undetermined') {
    await Notifications.requestPermissionsAsync();
  }
}
Enter fullscreen mode Exit fullscreen mode

Same guard, different call site. That is the whole change.

If you are prototyping this flow, this gating logic is exactly the kind of glue code worth generating rather than hand-writing. RapidNative scaffolds React Native and Expo screens from a prompt, and wiring a permission flow like this into a generated onboarding is a five-minute job.

The pre-permission modal trap

The popular advice says: show a custom in-app modal first ("We'd like to send you helpful reminders!"), and only fire the native prompt if the user taps yes.

The theory is sound. In practice it is easy to get wrong, and plenty of teams have watched it hurt more than help. You are now showing two interruptions instead of one, and the custom modal gives users a free, zero-cost place to say no. Some users who would have shrugged and accepted the native prompt bounce off the softer one first.

If you ask at a habit moment, the context does the explaining and the extra modal is redundant. My take: ship without it, and only add it if your own funnel data says otherwise. Do not cargo-cult it in.

Recovery for already-shipped apps

Already burned the prompt on install? You cannot re-trigger it, but you can route users to Settings at a habit moment instead:

import { Linking } from 'react-native';

async function recoverPush() {
  const { status, canAskAgain } = await Notifications.getPermissionsAsync();

  if (status === 'undetermined' || canAskAgain) {
    await Notifications.requestPermissionsAsync();
  } else if (status === 'denied') {
    // one-tap jump to your app's own settings page
    Linking.openSettings();
  }
}
Enter fullscreen mode Exit fullscreen mode

Pair Linking.openSettings() with a single line of UI ("Turn on reminders in Settings") and fire it only at a moment where the user just tried to do something that needs notifications. Cold recovery rates are low, but warm ones, triggered by intent, are worth shipping.

The whole thing

Counter, guard, trigger, recovery. About 20 lines total, no library beyond AsyncStorage and expo-notifications, and it protects the one prompt you cannot get back.

What trigger moment are you using in your app: session count, habit moment, or something weirder? Drop it in the comments.

Top comments (0)