●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Push Notification Masterplan for Rork Apps — Segmented Delivery, A/B Testing, and Automation Pipelines
An advanced guide to push notifications in Rork apps. Learn how to implement user segmentation, A/B testing, automated lifecycle pipelines, and retention-focused delivery strategies.
Setup and context — Why "Blast Everyone" Doesn't Work Anymore
Push notifications are one of the most powerful retention channels available to mobile app developers. But sending the same message to every user at the same time is a recipe for notification fatigue, rising opt-out rates, and diminishing returns.
Look at any top-performing app on the App Store or Google Play, and you'll find they all rely on segmented delivery based on user behavior and attributes, A/B testing to optimize messaging and timing, and event-driven automation pipelines to keep everything running without manual intervention.
In this guide, we'll walk through the architecture and implementation patterns for running production-grade push notifications in a Rork (React Native / Expo) app. This is aimed at developers who already have basic push notifications working — if you haven't set that up yet, start with our push notification fundamentals guide first.
Designing a Segmentation System — Delivering the Right Message to the Right User
Segmentation Dimensions
Effective segmented delivery starts with defining how you classify your users. Here are the three dimensions that work best in practice.
Attribute-based segments classify users by static properties captured at registration — language, region, plan tier, and signup date. These are simple to implement but offer limited personalization.
Behavior-based segments classify users by in-app actions — last activity date, session frequency, feature usage counts, and purchase history. Because they reflect what users are actually doing, they enable far more targeted messaging.
Lifecycle segments classify users by engagement stage — new, active, inactive, at-risk, and churned. Tailoring messages to each stage is the single most impactful thing you can do for retention.
Segment Management Table Design with Supabase
Let's design the database schema for managing segment data. Here's a Supabase (PostgreSQL) implementation:
-- User segment management tableCREATE TABLE user_segments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- Attribute segments locale TEXT DEFAULT 'ja', plan_type TEXT DEFAULT 'free', -- free / pro / premium registered_at TIMESTAMPTZ NOT NULL, -- Behavioral segments last_active_at TIMESTAMPTZ, session_count INTEGER DEFAULT 0, feature_usage JSONB DEFAULT '{}', -- Lifecycle segment (auto-calculated) lifecycle_stage TEXT GENERATED ALWAYS AS ( CASE WHEN last_active_at IS NULL THEN 'new' WHEN last_active_at > NOW() - INTERVAL '3 days' THEN 'active' WHEN last_active_at > NOW() - INTERVAL '14 days' THEN 'inactive' WHEN last_active_at > NOW() - INTERVAL '30 days' THEN 'at_risk' ELSE 'churned' END ) STORED, -- Notification preferences push_token TEXT, push_enabled BOOLEAN DEFAULT true, quiet_hours_start TIME DEFAULT '22:00', quiet_hours_end TIME DEFAULT '08:00', timezone TEXT DEFAULT 'Asia/Tokyo', updated_at TIMESTAMPTZ DEFAULT NOW());-- Indexes for segment queriesCREATE INDEX idx_segments_lifecycle ON user_segments(lifecycle_stage);CREATE INDEX idx_segments_plan ON user_segments(plan_type);CREATE INDEX idx_segments_push ON user_segments(push_enabled) WHERE push_token IS NOT NULL;
The key design choice here is the lifecycle_stage column using PostgreSQL's GENERATED ALWAYS AS ... STORED. Whenever last_active_at is updated, the lifecycle stage is automatically recalculated. No application-side logic needed — the database always reflects the current state.
Tracking Segment Data from the Rork App
We need a hook that automatically records user activity and feeds the segment table in real time:
// hooks/useSegmentTracker.tsimport { useEffect, useCallback } from 'react';import { AppState, AppStateStatus } from 'react-native';import { supabase } from '@/lib/supabase';import { useAuth } from '@/hooks/useAuth';export function useSegmentTracker() { const { user } = useAuth(); // Record a session whenever the app comes to foreground useEffect(() => { if (!user) return; const subscription = AppState.addEventListener( 'change', (nextState: AppStateStatus) => { if (nextState === 'active') { updateActivity(user.id); } } ); // Also record on initial mount updateActivity(user.id); return () => subscription.remove(); }, [user]); const updateActivity = useCallback(async (userId: string) => { const { error } = await supabase .from('user_segments') .upsert( { user_id: userId, last_active_at: new Date().toISOString(), session_count: supabase.rpc('increment_session_count', { target_user_id: userId, }), }, { onConflict: 'user_id' } ); if (error) { console.warn('Segment update failed:', error.message); } }, []); // Helper to track individual feature usage const trackFeatureUsage = useCallback( async (featureName: string) => { if (!user) return; const { data: current } = await supabase .from('user_segments') .select('feature_usage') .eq('user_id', user.id) .single(); const usage = (current?.feature_usage as Record<string, number>) || {}; usage[featureName] = (usage[featureName] || 0) + 1; await supabase .from('user_segments') .update({ feature_usage: usage }) .eq('user_id', user.id); }, [user] ); return { trackFeatureUsage };}
Mount this hook in your app's root layout, and session data flows automatically into your segment table. Call trackFeatureUsage from specific screens or actions to build up per-feature usage counts.
✦
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
✦Real-world numbers from operating apps with 50M+ cumulative downloads — segment design and opt-out rates
✦Shared notification infrastructure patterns for running multiple apps in parallel, with CPS-controlled dispatch
✦Continuous KPI monitoring and the improvement loop using BigQuery + Looker Studio
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.
This Edge Function accepts a segment filter, queries matching users, respects quiet hours, sends via the Expo Push API in batches, and logs everything to notification_logs automatically.
Building an A/B Testing Framework — Optimize with Data
Test Design Schema
To run notification A/B tests systematically, we need a proper data structure:
-- A/B test campaign managementCREATE TABLE notification_ab_tests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT DEFAULT 'draft', -- draft / running / completed segment_filter JSONB NOT NULL, -- Variant definitions (up to 4) variants JSONB NOT NULL, -- Example: [ -- { "id": "A", "weight": 50, "title": "Try our new feature", "body": "..." }, -- { "id": "B", "weight": 50, "title": "Your app just got better", "body": "..." } -- ] -- Success metrics winning_metric TEXT DEFAULT 'open_rate', -- open_rate / click_rate / conversion_rate confidence_level NUMERIC DEFAULT 0.95, -- Timeline started_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW());-- Per-user assignment recordsCREATE TABLE ab_test_assignments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), test_id UUID REFERENCES notification_ab_tests(id), user_id UUID REFERENCES auth.users(id), variant_id TEXT NOT NULL, -- Tracking timestamps delivered_at TIMESTAMPTZ DEFAULT NOW(), opened_at TIMESTAMPTZ, clicked_at TIMESTAMPTZ, converted_at TIMESTAMPTZ);CREATE INDEX idx_ab_assignments_test ON ab_test_assignments(test_id);CREATE INDEX idx_ab_assignments_variant ON ab_test_assignments(test_id, variant_id);
To accurately measure notification open rates, we embed tracking logic in the app:
// hooks/useNotificationTracking.tsimport { useEffect } from 'react';import * as Notifications from 'expo-notifications';import { supabase } from '@/lib/supabase';import { useAuth } from '@/hooks/useAuth';export function useNotificationTracking() { const { user } = useAuth(); useEffect(() => { if (!user) return; // Listener for when the user taps a notification const responseSubscription = Notifications.addNotificationResponseReceivedListener( async (response) => { const data = response.notification.request.content.data; const abTestId = data?.ab_test_id as string | undefined; const variantId = data?.variant_id as string | undefined; if (abTestId && variantId) { await supabase .from('ab_test_assignments') .update({ opened_at: new Date().toISOString() }) .eq('test_id', abTestId) .eq('user_id', user.id) .eq('variant_id', variantId); } // Navigate to deep link target if present const targetScreen = data?.screen as string | undefined; if (targetScreen) { // Use React Navigation or Expo Router // router.push(targetScreen); } } ); // Listener for foreground delivery const receivedSubscription = Notifications.addNotificationReceivedListener(async (notification) => { const data = notification.request.content.data; const abTestId = data?.ab_test_id as string | undefined; if (abTestId) { await supabase .from('ab_test_assignments') .update({ delivered_at: new Date().toISOString() }) .eq('test_id', abTestId) .eq('user_id', user.id); } }); return () => { responseSubscription.remove(); receivedSubscription.remove(); }; }, [user]);}
Analyzing Results and Statistical Significance
Here's the query to aggregate A/B test results and determine winners:
-- Aggregate performance metrics by variantSELECT variant_id, COUNT(*) AS total_sent, COUNT(opened_at) AS total_opened, COUNT(clicked_at) AS total_clicked, COUNT(converted_at) AS total_converted, ROUND(COUNT(opened_at)::NUMERIC / COUNT(*) * 100, 2) AS open_rate, ROUND(COUNT(clicked_at)::NUMERIC / NULLIF(COUNT(opened_at), 0) * 100, 2) AS click_rateFROM ab_test_assignmentsWHERE test_id = 'YOUR_TEST_ID'GROUP BY variant_idORDER BY open_rate DESC;
For statistical significance, ensure each variant has at least 300 deliveries. When the difference between variants is small (e.g., 10% vs 12% open rate), you'll need larger samples. Apply a Z-test to the proportions, either within a Supabase Edge Function or in your analytics dashboard.
The most impactful automation you can build is lifecycle-stage notifications — messages that go out automatically based on where users are in their journey:
// supabase/functions/lifecycle-notifications/index.ts// Triggered by pg_cron or external schedulerimport { serve } from 'https://deno.land/std@0.177.0/http/server.ts';import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';const LIFECYCLE_TEMPLATES: Record< string, { ja: { title: string; body: string }; en: { title: string; body: string } }> = { new_onboarding: { ja: { title: 'はじめの一歩を踏み出しましょう', body: 'チュートリアルを完了すると、アプリの可能性が広がります。今すぐ始めてみませんか?', }, en: { title: "Let's take the first step", body: 'Complete the tutorial to unlock the full potential of the app. Start now!', }, }, inactive_reminder: { ja: { title: 'お久しぶりです!新機能が追加されました', body: '前回のご利用から1週間が経ちました。最新のアップデートをチェックしてみてください。', }, en: { title: "We've missed you! New features await", body: "It's been a week since your last visit. Check out the latest updates!", }, }, at_risk_winback: { ja: { title: '特別なお知らせがあります', body: 'あなたのアカウントに限定オファーをご用意しました。ぜひご確認ください。', }, en: { title: 'A special offer just for you', body: "We've prepared an exclusive offer for your account. Come check it out!", }, },};serve(async () => { const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); const results: Record<string, number> = {}; // 1. Onboarding for new users (registered 24-48 hours ago) const { data: newUsers } = await supabase .from('user_segments') .select('user_id, push_token, locale') .eq('lifecycle_stage', 'new') .eq('push_enabled', true) .not('push_token', 'is', null) .lt('registered_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()) .gt('registered_at', new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString()); if (newUsers?.length) { await sendLocalizedBatch(newUsers, LIFECYCLE_TEMPLATES.new_onboarding); results['new_onboarding'] = newUsers.length; } // 2. Re-engagement for inactive users (7+ days) const { data: inactiveUsers } = await supabase .from('user_segments') .select('user_id, push_token, locale') .eq('lifecycle_stage', 'inactive') .eq('push_enabled', true) .not('push_token', 'is', null); if (inactiveUsers?.length) { await sendLocalizedBatch( inactiveUsers, LIFECYCLE_TEMPLATES.inactive_reminder ); results['inactive_reminder'] = inactiveUsers.length; } // 3. Win-back for at-risk users (14+ days) const { data: atRiskUsers } = await supabase .from('user_segments') .select('user_id, push_token, locale') .eq('lifecycle_stage', 'at_risk') .eq('push_enabled', true) .not('push_token', 'is', null); if (atRiskUsers?.length) { await sendLocalizedBatch( atRiskUsers, LIFECYCLE_TEMPLATES.at_risk_winback ); results['at_risk_winback'] = atRiskUsers.length; } return new Response(JSON.stringify({ sent: results }), { headers: { 'Content-Type': 'application/json' }, });});async function sendLocalizedBatch( users: Array<{ push_token: string; locale: string }>, template: { ja: { title: string; body: string }; en: { title: string; body: string } }) { const messages = users.map((u) => { const localized = u.locale === 'ja' ? template.ja : template.en; return { to: u.push_token, sound: 'default', title: localized.title, body: localized.body, }; }); for (let i = 0; i < messages.length; i += 100) { await fetch('https://exp.host/--/api/v2/push/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(messages.slice(i, i + 100)), }); }}
Scheduling with pg_cron
Use Supabase's pg_cron extension to run lifecycle notifications on a daily schedule:
-- Run lifecycle notifications daily at 10:00 AM JST (01:00 UTC)SELECT cron.schedule( 'lifecycle-notifications', '0 1 * * *', $$ SELECT net.http_post( url := 'https://your-project.supabase.co/functions/v1/lifecycle-notifications', headers := jsonb_build_object( 'Authorization', 'Bearer ' || current_setting('app.supabase_service_role_key') ), body := '{}' ); $$);
Retention-Focused Notification Strategies
Optimizing Notification Frequency
Over-notifying is the number one cause of opt-outs. Build these guardrails into your system:
Rate limiting: Cap the number of marketing notifications per user per week. A good starting point is 2-3 marketing notifications per week, with no limit on transactional notifications (payment confirmations, etc.).
// Check whether a user has exceeded the weekly notification limitasync function checkRateLimit( supabase: SupabaseClient, userId: string, maxPerWeek: number = 3): Promise<boolean> { const sevenDaysAgo = new Date( Date.now() - 7 * 24 * 60 * 60 * 1000 ).toISOString(); const { count } = await supabase .from('notification_delivery_log') .select('id', { count: 'exact' }) .eq('user_id', userId) .eq('notification_type', 'marketing') .gte('sent_at', sevenDaysAgo); return (count || 0) < maxPerWeek;}
Personalized send times: Analyze each user's historical open patterns and deliver at their peak engagement hour.
-- Calculate optimal send hour for each userSELECT user_id, EXTRACT(HOUR FROM opened_at AT TIME ZONE timezone) AS open_hour, COUNT(*) AS open_countFROM ab_test_assignments aJOIN user_segments s ON a.user_id = s.user_idWHERE opened_at IS NOT NULLGROUP BY user_id, open_hourORDER BY user_id, open_count DESC;
Rich Notifications for Higher Engagement
Expo Notifications supports rich notifications with images and action buttons. Leveraging these can significantly boost both open rates and click-through rates:
When running push notifications in production, track these metrics:
Delivery Rate: The percentage of sent notifications that actually reached user devices. If this drops below 90%, you likely have stale or invalid push tokens. Run regular cleanup jobs.
Open Rate: The percentage of delivered notifications that users tapped to open. Industry average is 5-15%. Aim for 20%+ through segmentation and A/B testing.
Opt-out Rate: The percentage of users who disabled notifications. If this exceeds 2% per month, reassess your notification frequency and content quality.
Conversion Rate: The percentage of notification-driven app opens that led to a target action (purchase, feature usage, etc.).
Cleaning Up Invalid Push Tokens
Use Expo Push API receipts to periodically purge tokens that are no longer valid:
In a Rork (Expo) environment, start with Expo Push Notifications. Expo's Push API handles both FCM (Android) and APNs (iOS) under the hood and abstracts away most of the platform differences. Layer FCM directly on top of Expo only when you actually need a feature Expo does not expose (topic subscriptions, data-only messages, etc.). For the Firebase-side A/B testing story, our Firebase Analytics and A/B testing guide is a useful companion read.
A/B test sample sizes and the operational trap
For statistically significant results, aim for at least 300 deliveries per variant. When the expected lift between variants is small (e.g., 10% vs 12% open rate), you will need substantially more — in my experience, 1,500 to 2,000 deliveries per variant is where I feel confident drawing a conclusion. With a small user base, focus on testing one variable at a time (title or body, not both) so the results are easier to interpret. One trap I have hit repeatedly: if a lifecycle notification reaches the same users through another channel while the test is running, the results get contaminated. Build a switch from day one that pauses other automated notifications for the test segment during the test window.
Operational Lessons That Are Not in the Documentation
The patterns covered up to this point come from official documentation and open-source best practices, but there is a layer of operational knowledge that rarely makes it into the docs. I have been running iOS and Android apps independently since 2013, primarily in the wallpaper, relaxation, and intention/manifestation genres, and the family of apps has accumulated over 50 million downloads. Push notification design has been rebuilt many times across these apps, and the following six lessons are the ones I would hand to my past self if I could.
1. Blasting the same notification to everyone takes more than half a year to recover from
This is the most expensive lesson I have ever paid for. When announcing a new feature in one of my apps, I sent a single promotional notification to every user — including dormant ones. Within a week, the share of users with valid push tokens dropped from roughly 62% to 47%, and it took about seven months to get back to the original level.
Recovery did not happen passively. The concrete steps I had to take were:
Tightening the cap on marketing notifications from "3 per week" down to "1 per week"
Introducing lifecycle-based delivery (active / inactive / at_risk / churned) with different copy for each stage
Adding a personalization token (typically the user's display name) at the beginning of every message — open rate moved from about 4.2% to 8.7%
Adding an in-app "notification frequency" setting; users who opted into a low-frequency mode received a single monthly digest
Even with these changes, the users who had stopped trusting the notifications largely did not come back. Cutting frequency takes a few days; rebuilding trust takes more than half a year. This is the strongest argument for adopting the segmentation patterns in this article before you ever need them.
2. Send times shift dramatically based on day of week and app genre
The industry average is often quoted as "open rates peak during lunch (12:00–13:00) and the evening commute (18:00–19:00)," but in practice the peak hours vary heavily by app genre. Measured across my own apps, the time-of-day heatmaps look like this:
Wallpaper apps: bimodal — morning commute (7:30–8:30) and late evening (22:00–23:30). Peak open rate ~14.8%, trough around 14:00 sits at ~4.2%
Relaxation apps: clear peaks on weekday evenings (21:00–23:00) and weekend mornings (Sat/Sun 10:00–12:00). Weekend peak open rate ~19.3%
Intention / manifestation apps: a single strong peak in the early morning (6:30–7:30), correlated with the user habit of "setting an intention for the day"
These segments emerged from three to four rounds of A/B testing per app. Do not trust the industry average — measure your own. Running the per-user "best send hour" query in this article for about 30 days will surface your app's specific patterns.
3. Push tokens decay much faster than you would expect
Expo push tokens are nominally durable, but in production they get invalidated for a number of reasons:
App reinstall (a new token is issued, the old one is invalidated)
On iOS, restoring from iCloud backup often rotates the token
On Android, device changes or settings resets rotate the token
The user disables notifications entirely from OS settings
Aggregating my operational data, about 12% of tokens come back as DeviceNotRegistered after 30 days from delivery. Even with the cleanupInvalidTokens job from earlier running daily, leaving things untouched for three months erodes the live token base by roughly 10%. Refreshing the token on every app launch and foreground resume — using the snippet below — is the single highest-leverage fix for maintaining delivery rate.
// hooks/usePushTokenRefresh.ts// Refresh the push token on app launch and on every foreground transitionimport { useEffect } from 'react';import { AppState } from 'react-native';import * as Notifications from 'expo-notifications';import { supabase } from '@/lib/supabase';import { useAuth } from '@/hooks/useAuth';export function usePushTokenRefresh() { const { user } = useAuth(); useEffect(() => { if (!user) return; const refreshToken = async () => { try { const { status } = await Notifications.getPermissionsAsync(); if (status !== 'granted') return; const tokenData = await Notifications.getExpoPushTokenAsync({ projectId: process.env.EXPO_PUBLIC_PROJECT_ID, }); const { data: existing } = await supabase .from('user_segments') .select('push_token') .eq('user_id', user.id) .single(); if (existing?.push_token !== tokenData.data) { await supabase .from('user_segments') .update({ push_token: tokenData.data, push_enabled: true, updated_at: new Date().toISOString(), }) .eq('user_id', user.id); } } catch (err) { // Never block app launch on token refresh console.warn('Push token refresh failed:', err); } }; refreshToken(); const sub = AppState.addEventListener('change', (state) => { if (state === 'active') refreshToken(); }); return () => sub.remove(); }, [user]);}
With this hook in place, the 30-day average for invalid tokens dropped from about 12% to about 4.8% in my apps. The cost of pushing notifications also went down measurably, because the wasted retries against APNs/FCM rate limits decreased by roughly 30%.
4. If you run more than one app, share the notification stack from day one
I currently run a portfolio of apps in parallel. Early on, each app had its own push notification implementation and its own dashboard. Adding a single new feature meant fanning the change across five or six repositories, which broke down very quickly.
The architecture I eventually consolidated to looks like this:
A single shared Supabase project, with an app_id column added to user_segments to make it multi-tenant
Edge Functions for segmented delivery, A/B testing, and lifecycle notifications are all shared and parameterized by app_id
notification_logs is also shared and exported to BigQuery so I can analyze across apps in one place
Notification copy templates live in Supabase Storage as YAML files, fetched by each app via app_id
The payoff is that any single notification improvement now ships to every app at once. If you are planning to operate two or more apps in parallel, design the shared infrastructure from the beginning. Retrofitting it later is, in my experience, five to ten times more expensive than building it that way from the start — I spent the better part of six months migrating.
5. Without CPS control, large pushes silently degrade
Once segmented delivery is live, peak campaigns send tens of thousands of notifications in a short window. Slamming the Expo Push API without any pacing eventually trips APNs/FCM rate limits (typically a few thousand per second), causing later batches to be delayed or to fail outright.
What I run in production is a queue-based dispatcher that caps deliveries at 500 per second:
// supabase/functions/rate-limited-dispatch/index.ts// Caps dispatch at 500/sec and uses exponential backoff for retriesinterface Message { to: string; sound: 'default'; title: string; body: string; data?: Record<string, string>;}const CPS = 500; // Max sends per secondconst BATCH_SIZE = 100; // Expo's per-request limitconst MAX_RETRY = 3; // Per-batch retry budgetasync function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms));}export async function rateLimitedDispatch(messages: Message[]): Promise<{ sent: number; retried: number; failed: number;}> { const result = { sent: 0, retried: 0, failed: 0 }; const batchPerSec = CPS / BATCH_SIZE; // = 5 batches/sec const intervalMs = 1000 / batchPerSec; // 1 batch every 200ms for (let i = 0; i < messages.length; i += BATCH_SIZE) { const batch = messages.slice(i, i + BATCH_SIZE); let attempt = 0; let success = false; while (attempt < MAX_RETRY && !success) { const res = await fetch('https://exp.host/--/api/v2/push/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(batch), }); if (res.status === 200) { success = true; result.sent += batch.length; } else if (res.status === 429 || res.status >= 500) { // Rate limit or server error → exponential backoff const wait = Math.min(2 ** attempt * 1000, 16_000); await sleep(wait); attempt += 1; if (attempt > 0) result.retried += batch.length; } else { // 4xx → permanent failure, bail out result.failed += batch.length; break; } } if (!success && attempt >= MAX_RETRY) { result.failed += batch.length; } await sleep(intervalMs); } return result;}
With this dispatcher in place, the retry rate during a large campaign dropped from about 8.5% to 0.7%. Total delivery time increases slightly, but the reduction in error rate and the stability under load are well worth it.
6. The moment you ask for notification permission decides your long-term retention
Everything above is about delivery. But the largest single lever I have found is when you ask for the notification permission in the first place. When I prompted for notifications on first launch, the iOS permission rate sat around 38%. The change that finally moved the number was:
Do not prompt on first launch at all
Add a screen inside the app that explains the value of receiving notifications
Defer the actual permission request until the user reaches a screen where notifications obviously belong (e.g., saving a favorite, setting a reminder, subscribing to a weekly summary), and ask with context
After this change, iOS opt-in moved from about 38% to about 64%. After Android 13 made permission opt-in mandatory, the same pattern lifted Android from about 54% to about 78%.
The implementation is just deferring requestPermissionsAsync() until after the relevant screen transition. The one thing you must add as a pair, however, is a fallback that routes already-denied users into OS settings — otherwise you cannot ask again:
// components/NotificationOptInModal.tsximport * as Linking from 'expo-linking';import * as Notifications from 'expo-notifications';import { Platform, Alert } from 'react-native';export async function requestPushPermissionWithFallback(reason: string): Promise<boolean> { const current = await Notifications.getPermissionsAsync(); if (current.status === 'granted') return true; if (current.canAskAgain) { const next = await Notifications.requestPermissionsAsync(); return next.status === 'granted'; } // Already denied → route to OS settings return new Promise((resolve) => { Alert.alert( 'Please enable notifications', `We use notifications for ${reason}. You can turn them on from Settings.`, [ { text: 'Later', style: 'cancel', onPress: () => resolve(false) }, { text: 'Open Settings', onPress: async () => { if (Platform.OS === 'ios') { await Linking.openURL('app-settings:'); } else { await Linking.openSettings(); } resolve(false); // user may grant on return; re-check after they come back }, }, ] ); });}
The order is "communicate value first, ask for permission second." That single ordering decision changes how many tokens your segmented delivery actually reaches.
Dashboards That Scale — BigQuery + Looker Studio
Once volume grows past a certain point, querying everything inside Supabase starts to feel sluggish, and dashboards lag noticeably. I eventually exported notification_logs and ab_test_assignments to BigQuery and built the dashboards in Looker Studio.
-- Daily KPI rollup materialized in BigQueryCREATE MATERIALIZED VIEW analytics.daily_push_kpiPARTITION BY dayCLUSTER BY app_idASSELECT DATE(sent_at, 'Asia/Tokyo') AS day, app_id, COUNT(*) AS total_targeted, SUM(total_sent) AS total_sent, SUM(total_opened) AS total_opened, SAFE_DIVIDE(SUM(total_opened), SUM(total_sent)) * 100 AS open_rate_pct, SAFE_DIVIDE(SUM(total_clicked), SUM(total_opened)) * 100 AS click_rate_pct, SUM(total_converted) AS total_convertedFROM analytics.notification_logs_rawGROUP BY day, app_id;
In Looker Studio, a single dashboard with app_id as a dimension and open_rate_pct / click_rate_pct / total_converted as metrics is enough to surface the notification health of every app in one view. Making anomalies visible at a glance is the prerequisite for long-term retention work.
The notification stack I ended up with — multi-tenant infrastructure, shared A/B testing, BigQuery-backed monitoring — took more than six months to build. But the underlying motivation has always been the same: building something that can be passed down to my children with reliability and trust intact. The short-term open rate numbers matter less than the long-term trust you maintain with users, and the longer you operate, the more obvious that trade-off becomes.
Summary and the One Next Step
Advanced push notification work moves through a cycle: segment design, delivery logic, A/B testing, automation pipelines, and monitoring for continuous improvement. Adopting the patterns covered in this guide step by step lowers opt-out rates while improving retention and conversion.
If I were to recommend one concrete first step after reading this far, it would be this: pull the last 30 days of notification logs for your app and plot open_rate and opt_out_rate on a single chart. In most cases this alone surfaces the biggest bottleneck — usually a gap between peak and trough hours that is too wide, or a single segment whose opt-out rate is dragging down the rest. From there, the segment table, A/B testing, lifecycle automation, and rate-limited dispatch in this article each become smaller, more targeted decisions instead of an overwhelming rewrite. Done in sequence over six to twelve months, the delivery quality of your app can comfortably reach a different tier.
If you are still getting comfortable with the basics, our Expo Notifications push implementation guide is the natural starting point.
Notification design is something I have rebuilt many times, mostly by way of failure. If anything here helps you avoid one of those rebuilds, that would mean a lot to me. Thank you for reading.
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.