●FUNDING — Rork raises $15M, drawing fresh attention to its mobile-first no-code AI positioning●MAX-NATIVE — Rork Max reaches native territory React Native can't: AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, HealthKit, and on-device Core ML●MOBILE-FIRST — While Bolt and Lovable focus on web apps, Rork builds mobile apps — production-ready from a plain-language description●WWDC — WWDC26 wraps with AI becoming a core OS capability; the iOS 27 generation raises the value of widgets and Live Activities●PRICING — Free to start, paid plans from $25/mo, Rork Max at $200/mo — ship fast on Expo, then go native with Max where it pays off●ALL-APPLE — Rork Max generates pure Swift covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●FUNDING — Rork raises $15M, drawing fresh attention to its mobile-first no-code AI positioning●MAX-NATIVE — Rork Max reaches native territory React Native can't: AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, HealthKit, and on-device Core ML●MOBILE-FIRST — While Bolt and Lovable focus on web apps, Rork builds mobile apps — production-ready from a plain-language description●WWDC — WWDC26 wraps with AI becoming a core OS capability; the iOS 27 generation raises the value of widgets and Live Activities●PRICING — Free to start, paid plans from $25/mo, Rork Max at $200/mo — ship fast on Expo, then go native with Max where it pays off●ALL-APPLE — Rork Max generates pure Swift covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage
You Only Get to Ask Once — Implementing a Notification Soft-Ask in Your Rork App to Lift Opt-In
On iOS, once a user denies the notification prompt you can never show it again. In a Rork (Expo) app, instead of firing the system prompt on launch, we add our own soft-ask screen and only request permission once the value has landed. Built with expo-notifications, covering Android 13 POST_NOTIFICATIONS, a recovery path after denial, and opt-in measurement.
I was lining up the notification opt-in rates across the apps I run when one of them stood out for being far too low. It was a wallpaper app built almost identically to the others, yet the share of users who allowed notifications was less than half of the rest.
The cause was easy to find. That one app was firing the notification permission dialog before the first screen even appeared. From the user's side: "I don't even know what this app is yet, and it's already asking to send me notifications."
iOS notification permissions come with a harsh rule that every indie developer hits at least once. The UNUserNotificationCenter dialog can only be shown automatically once in the app's lifetime. The moment a user taps "Don't Allow," calling requestPermissions() from code does nothing — the dialog never appears again. The only path left is asking them to open the Settings app themselves.
So opt-in is decided by when and how you ask. In this article we stop firing the system prompt on launch and slot in our own soft-ask (pre-permission) screen inside a Rork (Expo) app.
Why "you only get to ask once" governs opt-in
On iOS, notification permission has three states: notDetermined (never asked), authorized (granted), and denied. The key point is that the system prompt can only appear while the state is notDetermined. In any other state, requestPermissions() simply returns the current status without showing anything.
notDetermined is a single, once-per-lifetime chance. Spend it carelessly and there is no getting it back.
A soft-ask, by contrast, is our own UI that we can show as many times as we like. If the user declines it, the iOS state stays notDetermined. That is what makes the two-step pattern work: ask in our own dialog first, and fire the system prompt only for the people who agreed. By placing a cushion in front of the system prompt, we reserve that precious single shot for people who are genuinely likely to allow.
In my own apps, the difference between firing the system prompt on launch and asking only after the value had landed came out to more than a 2x gap in the final OS grant rate.
Decide the flow before writing code
Before implementation, lock down the order of decisions. The code is just this order written out.
On launch, read the current permission state. If authorized, do nothing.
If denied, don't show your own dialog; only surface a "you can turn notifications on in Settings" path where it's relevant.
Only while notDetermined, check whether a trigger to show the soft-ask has occurred.
When the trigger fires, show the soft-ask screen.
If the user taps "Turn on" in the soft-ask, only then fire the system prompt (requestPermissions()).
If they tap "Later," leave the state untouched and wait for the next trigger.
Of these six steps, many apps skip 3 and 4 and jump straight to 5 on launch. The whole value of the cushion lives here.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand why firing the system prompt on launch permanently burns your one chance on iOS, and implement a two-step soft-ask — your own dialog first, the system prompt only for those who agree — with expo-notifications
✦Defer the ask until the value has landed (first save, a completed task, the third launch) using a small TypeScript trigger, and design the three events you need to measure opt-in: shown, accepted, OS-granted
✦Build a recovery path that sends denied users to Settings via Linking.openSettings(), and handle the Android 13 POST_NOTIFICATIONS runtime permission difference, production gotchas included
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
First, a hook to read and write the current state via expo-notifications. Keeping state in one place rather than scattered across components pays off later when we measure.
// hooks/useNotificationPermission.tsimport { useCallback, useEffect, useState } from "react";import * as Notifications from "expo-notifications";type PermState = "loading" | "notDetermined" | "authorized" | "denied";export function useNotificationPermission() { const [state, setState] = useState<PermState>("loading"); const refresh = useCallback(async () => { const settings = await Notifications.getPermissionsAsync(); if (settings.granted) { setState("authorized"); } else if (settings.canAskAgain) { // canAskAgain === true means we can still show the system prompt setState("notDetermined"); } else { setState("denied"); } }, []); useEffect(() => { refresh(); }, [refresh]); // The system prompt is fired only here const requestSystemPrompt = useCallback(async () => { const result = await Notifications.requestPermissionsAsync(); await refresh(); return result.granted; }, [refresh]); return { state, refresh, requestSystemPrompt };}
The crux here is canAskAgain. If you judge iOS denied by the granted flag of getPermissionsAsync() alone, both notDetermined and denied come back as false and you can't tell them apart. When canAskAgain is false, you can be certain it's "the dialog can no longer be shown — i.e., denied." Miss this one line and you end up trying to show a dialog that will never appear, failing silently.
Build the soft-ask screen
Make the soft-ask a light modal that conveys, in one sentence, what notifications make better. Long copy goes unread. Stating the app-specific benefit concretely is what works. For a wallpaper app, "We'll let you know when new wallpapers are added"; for a habit app, "We'll gently nudge you at the time you chose so it's easier to keep going" — phrase it as the user's gain.
// components/SoftAskModal.tsximport { Modal, View, Text, Pressable, StyleSheet } from "react-native";type Props = { visible: boolean; onAllow: () => void; onDefer: () => void;};export function SoftAskModal({ visible, onAllow, onDefer }: Props) { return ( <Modal visible={visible} transparent animationType="fade"> <View style={styles.backdrop}> <View style={styles.card}> <Text style={styles.title}>We'll bring you new wallpapers</Text> <Text style={styles.body}> When wallpapers close to your taste are added, we'll let you know right away. Low frequency, and you can turn it off anytime. </Text> <Pressable style={styles.primary} onPress={onAllow}> <Text style={styles.primaryText}>Turn on notifications</Text> </Pressable> <Pressable style={styles.secondary} onPress={onDefer}> <Text style={styles.secondaryText}>Later</Text> </Pressable> </View> </View> </Modal> );}const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "center", padding: 24 }, card: { backgroundColor: "#fff", borderRadius: 20, padding: 24 }, title: { fontSize: 20, fontWeight: "700", marginBottom: 8 }, body: { fontSize: 15, lineHeight: 22, color: "#444", marginBottom: 20 }, primary: { backgroundColor: "#111", borderRadius: 14, paddingVertical: 14, alignItems: "center" }, primaryText: { color: "#fff", fontSize: 16, fontWeight: "600" }, secondary: { paddingVertical: 12, alignItems: "center", marginTop: 4 }, secondaryText: { color: "#888", fontSize: 15 },});
Always include "Later." A modal with no escape pushes reluctant users toward uninstalling. Since declining leaves the iOS state at notDetermined, you can simply ask again at the next trigger. Not rushing actually raises opt-in.
Deciding when to ask — trigger design
The best moment for the soft-ask is right after the user has felt the value of notifications. Concrete candidates:
Right after saving or favoriting the first piece of content
Right after completing a task or a setup step
On the third or later launch, while still undecided
In my apps, "right after the first save" worked most naturally. A save is an expression of "I want to use this again," so people agree to notifications on that same momentum. Conversely, gating on launch count alone shows the ask to people who haven't experienced anything yet, and they decline more easily.
Hold the trigger decision as a small piece of state. Use AsyncStorage to remember "have we asked" and "how many times was Later tapped" so you don't become a nag.
// lib/softAskTrigger.tsimport AsyncStorage from "@react-native-async-storage/async-storage";const KEY = "softask.v1";type SoftAskRecord = { deferred: number; lastShownAt: number | null };export async function shouldShowSoftAsk(): Promise<boolean> { const raw = await AsyncStorage.getItem(KEY); const rec: SoftAskRecord = raw ? JSON.parse(raw) : { deferred: 0, lastShownAt: null }; // After "Later" is tapped twice, stop showing it automatically (don't be noisy) if (rec.deferred >= 2) return false; // If shown within the last 3 days, leave some space if (rec.lastShownAt && Date.now() - rec.lastShownAt < 3 * 24 * 60 * 60 * 1000) { return false; } return true;}export async function recordShown() { const raw = await AsyncStorage.getItem(KEY); const rec: SoftAskRecord = raw ? JSON.parse(raw) : { deferred: 0, lastShownAt: null }; rec.lastShownAt = Date.now(); await AsyncStorage.setItem(KEY, JSON.stringify(rec));}export async function recordDeferred() { const raw = await AsyncStorage.getItem(KEY); const rec: SoftAskRecord = raw ? JSON.parse(raw) : { deferred: 0, lastShownAt: null }; rec.deferred += 1; await AsyncStorage.setItem(KEY, JSON.stringify(rec));}
The cap and the spacing exist because exploiting "you can retry after a decline" to show it over and over is itself what builds the impression of a noisy app. A restraint of "back off after two declines" feels about right for keeping people around for the long run.
Wire it into the save action
Now drop these parts into the real "save" handler. When a save succeeds, check the trigger and show the soft-ask.
// inside the save button handlerimport { shouldShowSoftAsk, recordShown, recordDeferred } from "../lib/softAskTrigger";function WallpaperDetail() { const { state, requestSystemPrompt } = useNotificationPermission(); const [askVisible, setAskVisible] = useState(false); const handleSave = async () => { await saveWallpaper(); // the actual save // Only present when undecided and the trigger condition is met if (state === "notDetermined" && (await shouldShowSoftAsk())) { await recordShown(); track("softask_shown"); // <- measurement event (1) setAskVisible(true); } }; const handleAllow = async () => { setAskVisible(false); track("softask_accepted"); // <- measurement event (2) const granted = await requestSystemPrompt(); track(granted ? "os_permission_granted" : "os_permission_denied"); // <- (3) }; const handleDefer = async () => { setAskVisible(false); await recordDeferred(); track("softask_deferred"); }; return ( <> {/* screen body omitted */} <SoftAskModal visible={askVisible} onAllow={handleAllow} onDefer={handleDefer} /> </> );}
Calling requestSystemPrompt() only inside handleAllow is the heart of the design. The system prompt reaches only "people who agreed in our own dialog." That narrows the once-only notDetermined chance down to the genuinely willing.
The three events that measure opt-in
Once the soft-ask is in, measure it without fail — you can only improve what you can see. At minimum, record these three events:
softask_shown — how many times the custom dialog was shown
softask_accepted — how many times "Turn on" was tapped
os_permission_granted — how many times the OS actually granted
From these, derive two rates: "accepted / shown," which reflects how persuasive the soft-ask is, and "granted / shown," the final outcome. If the former is low, suspect the copy or where you show it; if only the latter is low, suspect the experience right before the system prompt. In my case, simply rewriting the copy from "describing the feature" to "the user's gain" visibly raised the accepted rate.
If you already run PostHog or Mixpanel, lining up these three events as a funnel shows at a glance where people drop off.
What to do with users who deny
A certain share will still end up denied. Rather than giving up and going silent, surface a path to Settings exactly once, only where notifications truly matter.
import * as Linking from "expo-linking";import { Alert } from "react-native";function promptOpenSettings() { Alert.alert( "Notifications are off", "Turn them on in the Settings app and we can let you know about new arrivals.", [ { text: "Close", style: "cancel" }, { text: "Open Settings", onPress: () => Linking.openSettings() }, ] );}
Linking.openSettings() opens your app's own settings page directly on both iOS and Android. The caveat: don't overuse this path either. Rather than showing it on every screen once denied, limit it to "when the user themselves opens a feature that presupposes notifications." Being prompted to change settings somewhere they didn't ask for is as unpleasant as spamming the soft-ask.
Don't forget the Android 13 difference
Look only at iOS and you'll trip in production. As of Android 13 (API 33), notifications became a runtime permission called POST_NOTIFICATIONS. On earlier Android, notifications are on by default and no permission is needed; from 13 onward, an explicit grant is required just like iOS.
expo-notifications absorbs this difference in requestPermissionsAsync(), but you have to add the configuration on the app.json side yourself.
Unlike iOS, Android lets you re-show the dialog a limited number of times after a denial. But after two consecutive denials, canAskAgain becomes false just like iOS, and from then on only the Settings path remains. In other words, the design philosophy of "don't ask noisily" ends up paying off on both operating systems. Because the Expo project Rork generates handles both OS differences from one codebase, you avoid piling on branches.
One last thing before you ship
The soft-ask implementation itself is a small job — less than half a day. But notifications are one of the few legitimate routes back into your app. Whether you spend that one-time dialog on launch or save it until the value has landed — that single decision changes the foundation of your retention.
As a next step, check where your live apps fire the system prompt today. If it's on launch, start by adding a single measurement event like softask_shown there, and the impact of any improvement will show up directly in the numbers.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.