Push notifications during onboarding walk a fine line. Sent at the right time with the right message, they bring users back to complete setup and reach their first value moment. Sent too early, too often, or with generic copy, they get your app muted or uninstalled. This guide covers when, what, and how to send onboarding push notifications that actually help.
For email-based onboarding, see Onboarding Email Sequences with Deep Links. For broader notification strategy, see Push Notification Strategy for Mobile Apps.
When to Send Onboarding Pushes
The Timing Rules
| Scenario | When to Send | Deep Link Target |
|---|---|---|
| User left during signup | 4-6 hours later | /onboarding/signup |
| Signup complete, no first action | 24 hours later | /onboarding/first-action |
| First action done, setup incomplete | 48 hours later | /onboarding/resume |
| Haven't opened app in 3 days | Day 3 | /home with welcome back context |
| Feature not yet discovered | Day 5-7 | /feature/{feature_name} |
What Not to Do
- Don't send a push notification within the first hour. The user just left your app; give them space.
- Don't send more than 1 onboarding push per day.
- Don't send pushes after 9pm or before 8am in the user's local time.
- Don't send a "Welcome!" push immediately after signup. The user literally just saw your app.
Permission Timing
You need push permission before you can send anything. The best time to ask:
function shouldAskPushPermission(user) {
// Don't ask on first screen
if (user.onboardingStep === 0) return false;
// Ask after the user has experienced value
if (user.hasCompletedFirstAction) return true;
// Ask after they've spent at least 2 minutes in the app
if (user.sessionDuration > 120000) return true;
return false;
}
async function requestPushWithContext(reason) {
// Show a pre-permission screen first
const userConsent = await showPrePermission({
title: 'Stay in the loop',
body: reason,
allowText: 'Enable',
denyText: 'Not Now',
});
if (userConsent) {
const granted = await requestPushPermission();
return granted;
}
return false;
}
Pre-permission screens increase grant rates by 30-50% because the user understands why before the system prompt appears. For a broader look at onboarding fundamentals (including when to request permissions), see Onboarding Best Practices for Mobile Apps in 2026.
Notification Content
Copy Patterns That Work
Incomplete signup:
Title: Pick up where you left off
Body: Your account is almost ready. Just one more step.
Deep link: /onboarding/signup
No first action:
Title: Ready to create your first [item]?
Body: It takes about 30 seconds. Give it a try.
Deep link: /create
Feature discovery:
Title: Did you know you can [feature benefit]?
Body: [One sentence about the feature]. Try it now.
Deep link: /feature/sharing
Progress reminder:
Title: 2 of 3 steps done
Body: Finish your last step to unlock all features.
Deep link: /onboarding/resume
Copy Patterns That Don't Work
| Bad Copy | Why It Fails |
|---|---|
| "We miss you!" | Needy, no value proposition |
| "Come back to [App]!" | No reason given |
| "You have unread notifications" | Misleading if there are none |
| "Don't miss out!" | Vague urgency |
| "Hey! 👋" | No substance |
Every push notification should answer: "What will the user gain by tapping this?"
Deep Link Implementation
Generating Deep Links for Push
async function createOnboardingPushLink(userId, destination) {
const link = await Tolinku.createLink({
path: destination,
params: {
source: 'push',
campaign: 'onboarding',
user_id: userId,
},
});
return link.url;
}
Sending the Push with Deep Link
async function sendOnboardingPush(userId, notification) {
const deepLink = await createOnboardingPushLink(userId, notification.deepLink);
await pushService.send(userId, {
title: notification.title,
body: notification.body,
data: {
deepLink: deepLink,
type: 'onboarding',
campaign: notification.campaign,
},
});
analytics.track('onboarding_push_sent', {
userId,
campaign: notification.campaign,
deepLink: notification.deepLink,
});
}
Handling Push Tap in the App
function handlePushNotificationTap(notification) {
const data = notification.data;
analytics.track('onboarding_push_tapped', {
campaign: data.campaign,
deepLink: data.deepLink,
});
if (data.deepLink) {
// Navigate directly to the target screen
handleDeepLink(data.deepLink);
}
}
Scheduling Logic
The Onboarding Push Scheduler
async function scheduleOnboardingPushes(userId) {
const user = await getUser(userId);
const timezone = user.timezone || 'UTC';
const pushes = [
{
condition: () => user.onboardingCompleted === false && user.hasAccount,
delay: '24h',
notification: {
title: 'Finish setting up',
body: 'Complete your profile to get personalized recommendations.',
deepLink: '/onboarding/resume',
campaign: 'onboarding_day1',
},
},
{
condition: () => user.hasCompletedFirstAction === false,
delay: '48h',
notification: {
title: 'Create your first [item]',
body: 'See why thousands of people use [App] every day.',
deepLink: '/create',
campaign: 'onboarding_day2',
},
},
{
condition: () => user.hasUsedFeature('sharing') === false && user.itemCount >= 1,
delay: '5d',
notification: {
title: 'Share your work',
body: 'Invite friends or colleagues to see what you have created.',
deepLink: '/feature/sharing',
campaign: 'onboarding_day5',
},
},
];
for (const push of pushes) {
schedulePush(userId, push, timezone);
}
}
function schedulePush(userId, push, timezone) {
const sendAt = addDuration(new Date(), push.delay);
const localSendAt = adjustToLocalTime(sendAt, timezone, { minHour: 9, maxHour: 20 });
pushQueue.add({
userId,
sendAt: localSendAt,
notification: push.notification,
condition: push.condition, // Re-evaluated at send time
});
}
Re-Evaluate Conditions at Send Time
The user may have completed the action between scheduling and send time:
async function processPushQueue(job) {
const user = await getUser(job.userId);
// Re-check the condition
if (job.condition(user) === false) {
// User already completed the action; skip this push
return;
}
// Check push permission is still granted
if (user.pushEnabled === false) {
return;
}
await sendOnboardingPush(job.userId, job.notification);
}
Frequency Capping
Rules
const PUSH_LIMITS = {
maxPerDay: 1,
maxPerWeek: 3,
maxOnboardingTotal: 5,
minTimeBetweenPushes: 12 * 60 * 60 * 1000, // 12 hours
};
async function canSendPush(userId) {
const recentPushes = await getRecentPushes(userId);
const today = recentPushes.filter(p => isToday(p.sentAt));
if (today.length >= PUSH_LIMITS.maxPerDay) return false;
const thisWeek = recentPushes.filter(p => isThisWeek(p.sentAt));
if (thisWeek.length >= PUSH_LIMITS.maxPerWeek) return false;
const onboardingPushes = recentPushes.filter(p => p.campaign.startsWith('onboarding_'));
if (onboardingPushes.length >= PUSH_LIMITS.maxOnboardingTotal) return false;
const lastPush = recentPushes[0];
if (lastPush && (Date.now() - lastPush.sentAt) < PUSH_LIMITS.minTimeBetweenPushes) return false;
return true;
}
Measuring Push Effectiveness
Key Metrics
| Metric | Formula | Benchmark |
|---|---|---|
| Delivery rate | Delivered / Sent | > 95% |
| Open rate | Tapped / Delivered | 5-15% for onboarding |
| Deep link completion | Reached target screen / Tapped | 80-90% |
| Action completion | Completed target action / Tapped | 15-30% |
| Opt-out rate (per push) | Disabled after push / Delivered | < 0.5% |
Track Push-Attributed Actions
analytics.track('onboarding_action_completed', {
action: 'first_item_created',
attributedTo: 'push', // or 'email', 'organic'
pushCampaign: 'onboarding_day2',
timeFromPush: Date.now() - lastPushTappedAt,
});
Optimization
If open rates are below 5%, the problem is timing or copy. If open rates are above 10% but action completion is below 10%, the problem is the deep link target or the in-app experience after the tap. For users who've stopped responding to onboarding pushes entirely, transition them to a dedicated re-engagement campaign.
For deep linking features, see Tolinku deep linking. For onboarding use cases, see the onboarding documentation.
Get deep linking tips in your inbox
One email per week. No spam.