We once worked on an app that sent a welcome email, an onboarding email, a 'you haven't logged in' email, a feature announcement, a weekly digest, and a billing reminder — all within the first 48 hours of signing up. The unsubscribe rate was catastrophic. Nobody complained because they just quietly left. That's the thing about bad notification emails: users don't tell you they hate them. They just stop opening them, then mark you as spam, then churn.
This post is our hard-won framework for thinking about notification emails — when to send them, what to put in them, and how to not accidentally train your users to ignore everything you send.
The core rule: every email needs a job
Before writing a single line of email template, ask: what action does this email enable or confirm? If you can't answer that in one sentence, the email probably shouldn't exist. 'Keep users engaged' is not a job. 'Tell the user their export is ready to download' is a job. 'Notify them that a teammate commented on their document' is a job.
Notification emails fall into roughly four categories, and each has different rules for frequency and content:
- Transactional — triggered by a user action (receipt, password reset, export ready). Send immediately, no frequency limit.
- Activity — something happened that affects the user (a comment, a mention, a status change). Batch intelligently.
- Lifecycle — based on where someone is in their journey (trial ending, onboarding incomplete). Send once, maybe twice.
- Marketing — announcements, features, digests. Treat these completely differently and let users opt out easily.
The mistake most apps make is treating all four categories the same. They use the same unsubscribe flow, the same sending frequency, the same tone. Transactional emails have near-100% open rates because people expect and want them. Marketing emails have 20-30% open rates on a good day. Don't let one poison the other.
Frequency: the math nobody does
Here's a quick thought experiment. You have 1000 users. You send a weekly digest, a monthly roundup, onboarding emails (say 3 over 2 weeks), and activity notifications (averaging 2 per week per active user). For an active user in their first month, that's roughly 12-15 emails. That's fine. For a user who doesn't really use your app, they get the onboarding sequence plus weekly digests — and they have no idea why they're getting any of it.
The fix is segmentation by activity level. This is easier than it sounds:
// Simple activity-based email gating
type UserActivityTier = 'active' | 'passive' | 'dormant';
function getUserActivityTier(user: {
lastLoginAt: Date;
actionsLast30Days: number;
}): UserActivityTier {
const daysSinceLogin = Math.floor(
(Date.now() - user.lastLoginAt.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceLogin <= 7 && user.actionsLast30Days >= 5) {
return 'active';
}
if (daysSinceLogin <= 30) {
return 'passive';
}
return 'dormant';
}
// Then in your email sending logic:
const tier = getUserActivityTier(user);
// Active users: send activity notifications, weekly digests, feature announcements
// Passive users: send weekly digest only, no activity notifications
// Dormant users: send a single re-engagement email, nothing elseDormant users are a trap. The instinct is to email them more to 'win them back'. The reality is they'll mark you as spam, which tanks your sender reputation for everyone else. Send one thoughtful re-engagement email with a clear value proposition. If they don't engage, stop. You can try again in 3 months.
Batching activity notifications (this will save you)
GitHub does this well. You don't get an email for every comment on a PR. You get one email with all the comments since you last checked. If you're building anything collaborative — comments, mentions, approvals, status updates — you need notification batching. Sending one email per event is how you end up in the spam folder by Tuesday.
A practical batching pattern with a queue:
// Notification batching with a simple queue approach
// Works great with Upstash QStash or similar delay queues
interface PendingNotification {
userId: string;
type: string;
data: Record<string, unknown>;
createdAt: Date;
}
async function queueNotification(notification: PendingNotification) {
// Store in your DB
await db.pendingNotification.create({ data: notification });
// Schedule a batch job to run in 15 minutes
// If a job is already scheduled for this user, the upsert handles it
await db.notificationBatchJob.upsert({
where: { userId: notification.userId },
create: {
userId: notification.userId,
sendAt: new Date(Date.now() + 15 * 60 * 1000),
},
update: {
// Don't reset the timer — send everything accumulated so far
// This prevents infinite delays if events keep coming
},
});
}
async function processBatchForUser(userId: string) {
const pending = await db.pendingNotification.findMany({
where: { userId, sentAt: null },
orderBy: { createdAt: 'asc' },
});
if (pending.length === 0) return;
// Group by type for a cleaner email
const grouped = pending.reduce((acc, n) => {
if (!acc[n.type]) acc[n.type] = [];
acc[n.type].push(n);
return acc;
}, {} as Record<string, PendingNotification[]>);
await sendBatchNotificationEmail(userId, grouped);
await db.pendingNotification.updateMany({
where: { id: { in: pending.map(n => n.id) } },
data: { sentAt: new Date() },
});
}The 15-minute window is a reasonable default for most apps. Slack uses something similar — you don't get notified for every message if you're actively in a channel. Adjust based on how time-sensitive your notifications are. For a collaborative doc editor, 15 minutes is fine. For a security alert, send immediately.
Content: what to actually put in the email
Here's our template for what every notification email should contain:
- Subject line: specific enough that they know what happened without opening. 'New comment on your post' not 'Activity on Acme App'.
- One sentence of context at the top: who did what, when.
- The actual content: show the comment, the change, the data — don't just link to it.
- One clear CTA: usually 'View in app'. Not three buttons.
- Footer with unsubscribe and notification settings link. Make the settings link prominent — it's better than an unsubscribe.
The 'show the actual content' point is worth expanding. When someone comments on your document, don't just email 'You have a new comment'. Show the comment text. If it's a batched digest, show the first sentence of each comment. The email should be useful on its own, not just a notification that something happened. If someone reads the email and can respond in their head without clicking through, you've done it right.
The goal is not to drive clicks. The goal is to keep the user informed. Sometimes that means they read the email and move on without clicking — and that's fine. An email that keeps users informed builds trust. An email that withholds information to force a click trains users to ignore you.
Subject lines: be boring and specific
We've tested this more than we'd like to admit. Clever subject lines lose to boring, specific ones every time for notification emails. 'Ana replied to your comment on Q3 Report' will always outperform 'Someone's talking about your stuff!'. The clever version might work once for a marketing email. For notifications, users are scanning their inbox and making a split-second decision about relevance. Give them the information upfront.
A few patterns that consistently work:
- [Person] [action] on [thing]: 'Mihai commented on Project Alpha'
- [Thing] is [status]: 'Your export is ready to download'
- [Number] new [events] on [thing]: '3 new comments on your PR'
- Action required: [specific thing]: 'Action required: confirm your email address'
Avoid: exclamation marks, ALL CAPS, emoji in subject lines for transactional emails (fine for marketing), vague teasers, re-engagement bait like 'You won't believe what you missed'.
User preferences: give control, set sane defaults
You need a notification preferences page. Not just an unsubscribe link. Let users control what they get. Most people won't change the defaults, but the ones who do are your power users — and annoying them is expensive.
// Notification preferences schema (simplified)
interface NotificationPreferences {
// Activity notifications
comments: 'immediate' | 'batched_15m' | 'batched_daily' | 'off';
mentions: 'immediate' | 'batched_15m' | 'off'; // never batch mentions
statusChanges: 'immediate' | 'batched_daily' | 'off';
// Digest emails
weeklyDigest: boolean;
// Lifecycle (these are usually non-negotiable)
trialReminders: boolean; // consider making this opt-out only
billingAlerts: boolean; // never let users turn this off
// Marketing
productUpdates: boolean;
tips: boolean;
}
const DEFAULT_PREFERENCES: NotificationPreferences = {
comments: 'batched_15m',
mentions: 'immediate',
statusChanges: 'batched_daily',
weeklyDigest: true,
trialReminders: true,
billingAlerts: true, // forced on, not exposed in UI
productUpdates: true,
tips: false, // opt-in only — less noise by default
};The defaults matter enormously. Setting `tips: false` by default means only engaged users who actively turn it on will get tips content — which means your open rates on that email will be high and your spam complaints will be low. Counterintuitively, being conservative with defaults improves your email metrics.
A few hard rules on preferences: billing alerts should never be opt-out. You have a legal and practical obligation to tell someone their card failed or their subscription changed. Security emails (password changed, new login from unknown device) should also be mandatory. Everything else is fair game for user control.
The unsubscribe UX nobody talks about
When someone clicks unsubscribe from a notification email, the worst thing you can do is unsubscribe them from everything. They probably wanted to stop the weekly digest, not the receipts and alerts. One-click unsubscribe from everything is required by law (CAN-SPAM, GDPR), but you can present a preferences page first that lets them be more surgical.
The flow we use: unsubscribe link → preference page pre-loaded with their current settings → 'Turn off all marketing emails' as a big button option, plus granular toggles → save. Users who want to nuke everything still can in one click. But about 40% of users who land on that page adjust preferences instead of unsubscribing entirely. That's real retention.
Also: add List-Unsubscribe headers to your emails. Gmail and Apple Mail show a native unsubscribe option when these headers are present. Users who use that native button instead of the email link are less likely to mark you as spam. Your email provider (Resend, Postmark, SendGrid) makes this easy to set.
If you're using a peal.dev template, the email setup with Resend already includes the right headers and a notification preferences pattern — you're not starting from zero on this stuff.
Measuring if your emails are working
Open rate is a vanity metric for notification emails, especially since Apple Mail Privacy Protection broke pixel tracking. What actually matters:
- Spam complaint rate: should be below 0.1%. Above 0.08% and Gmail starts throttling you.
- Unsubscribe rate per email type: if one type has 5x the unsubscribe rate, that's a signal.
- Click-to-open rate: of people who opened, how many clicked? Low CTOR means your content isn't matching their expectations.
- Delivery rate: emails that don't bounce. Below 95% and your list hygiene needs work.
Track these per email type, not in aggregate. Your transactional emails will have great numbers. Don't let them hide poor performance from your digest or lifecycle emails.
One thing we've found genuinely useful: occasional direct feedback. Three months in, we sent a short 3-question survey to users who'd been active for 30+ days asking about email frequency. 60% said the frequency was fine, 30% said they'd prefer less, 10% wanted more. We adjusted defaults based on that. Took an afternoon. Probably saved us hundreds of spam complaints over the next year.
The north star for notification emails is simple: every email you send should make users slightly more confident that opening the next email is worth their time. The moment you betray that trust — with irrelevant content, weird frequency, or bait-and-switch subject lines — you're spending down a balance that's hard to rebuild. Be boring, be relevant, be predictable. Your unsubscribe rate will thank you.
