Day-one users saw the system permission dialog before they understood why notifications mattered. Opt-in landed under twenty percent. Product blamed "Flutter push setup." Engineering had wired FCM correctly. The timing was wrong.
Push notifications depend on permission state, platform rules, and when you ask. Apple and Google both punish apps that prompt on first launch with no context. This post covers UX patterns and Flutter implementation choices that protect opt-in rates, separate from store submission checklists or deep link setup covered elsewhere.
If you are still deciding platform scope, our iOS, Android, or cross-platform app planning guide covers when push-heavy products justify native or cross-platform builds. This article assumes you already need notifications and want them to actually reach users.
Mental model: three steps
- Product value (user understands what notifications do for them)
- Pre-permission screen (your copy, your button, not the OS dialog yet)
-
System permission (iOS
requestAuthorization, Android 13+ runtime POST_NOTIFICATIONS)
Skip step 2 and step 1, and step 3 becomes a coin flip.
When not to ask on first open
Do not trigger the system dialog when:
- User has not completed onboarding or first meaningful action
- Notifications are optional for core value (browser-style informational apps)
- You only plan marketing blasts with no transactional value
- Login is required but user has not signed in yet
Better triggers:
- After first booking, order, or message received
- When user enables "order updates" or "reminder" in settings
- After they favorite or follow something that can notify them
Align prompt timing with a specific benefit, not "stay engaged."
Pre-permission screen (pattern)
Show one full screen or modal before calling the OS API:
- Headline: what they get ("Get notified when your order ships")
- Two bullets max: transactional examples, not vague "updates"
- Primary button: "Turn on notifications" (you control this label)
- Secondary: "Not now" (respect dismiss; do not re-prompt same session)
Only on primary tap call platform permission APIs.
Flutter note: build this as a normal route or dialog. No plugin required for the education layer.
iOS: provisional vs full authorization
iOS offers nuanced paths:
| Type | User experience | Use when |
|---|---|---|
| Provisional | Quiet notification center delivery without alert/badge/sound prompt upfront | Low-risk informational; user can upgrade to full later |
| Full (alert, badge, sound) | Standard permission dialog | Transactional alerts user explicitly opted into |
Implementation sketch (via firebase_messaging or flutter_local_notifications + platform channels):
- Request provisional first only if product strategy supports quiet delivery
- For most B2C transactional apps, aim for full authorization after pre-permission screen
- Check
AuthorizationStatuson app resume; settings deep link if denied
Common miss: calling requestPermission in main() before runApp finishes first frame. User sees dialog over splash with zero context.
Android: API 33+ runtime permission
Android 13 (API 33) introduced POST_NOTIFICATIONS as a runtime permission.
Checklist:
- [ ] Target SDK meets Play requirements
- [ ] Declare permission in
AndroidManifest.xml - [ ] Request at runtime after pre-permission screen (not in
main()on cold start) - [ ] Handle denied and don't ask again paths with in-app settings CTA
- [ ] Test on Android 12 and 13+ devices (behavior differs)
Flutter: use permission_handler or FCM plugin helpers that wrap Android 13+ flow. Verify plugin version supports POST_NOTIFICATIONS.
Older Android versions grant notification ability at install time; do not assume one code path for all OS versions.
Flutter implementation timing (code-level)
Avoid:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await FirebaseMessaging.instance.requestPermission(); // too early
runApp(MyApp());
}
Prefer:
- Initialize Firebase in
main()if needed - Register background handlers
- Defer
requestPermission()until user taps your pre-permission CTA - Persist "user declined pre-prompt" to avoid nagging every launch
After grant:
- Fetch FCM token and send to backend after permission granted (not before)
- Handle token refresh and associate with logged-in user id server-side
- Test token invalidation on logout
Backend token storage is where mobile and cross-platform product delivery teams often integrate FCM with existing auth: one user, one device table, idempotent token upsert.
Re-prompting without annoying users
If user tapped "Not now" on your pre-permission screen:
- Wait for a natural retry moment (second order, settings visit)
- Do not show system dialog again until they tap your CTA
- If system denied permanently, deep link to OS app settings with short instructions
Track funnel events: pre_prompt_shown, pre_prompt_accepted, system_permission_granted, system_permission_denied. Product decisions need counts, not guesses.
Testing checklist
- [ ] Cold install: no system dialog before pre-permission (unless intentional provisional strategy)
- [ ] Pre-permission copy matches actual notification types you send
- [ ] Grant path: token reaches backend and test push arrives on physical device
- [ ] Deny path: app remains usable; no crash on null token
- [ ] iOS physical device (simulator push behavior is limited)
- [ ] Android 13+ device for runtime permission flow
- [ ] Logout clears or invalidates device token server-side
Key takeaways
- Timing beats plumbing: correct FCM setup with day-one prompt still fails opt-in.
- Use a pre-permission screen tied to a concrete user benefit.
- iOS provisional vs full is a product choice, not only an engineering default.
- Android 13+ needs explicit runtime request; test multiple OS versions.
- Defer
requestPermission()until the user opts in on your UI.
Where does your app ask today: first launch, after login, or only from settings?
If push is core to your product and you want launch help across iOS and Android, see our mobile and cross-platform delivery scope.
Top comments (1)
The pre-permission screen distinction is the part worth stealing for any feature gated behind a system prompt, not just push. I've been writing about AI features that need similar staged disclosure (confidence tiers before an action fires), and the pattern is the same: don't let the OS or the model's first output be the user's first exposure to why something is happening. Tracking pre_prompt_shown vs system_permission_granted separately is a good call too, most teams only measure the second and wonder why opt-in is low.