How I built a click tracking system to measure real engagement rates, analyze user behavior by hour, and optimize notification timing for 500,000+ push campaigns?
"I sent 500,000 notifications. How many people actually opened them?"
After launching my push notification system, my product manager asked the most important question. I could track delivery rates (80% success), but I had no idea about engagement. Did users just dismiss the notification? Or did they actually open the app?
Turns out, measuring this is harder than it sounds. Push notifications don't automatically report "opens" like emails do. You have to build your own tracking system. In this post, I'll show you how I implemented click tracking for 500,000+ user campaigns and discovered surprising insights about user behavior.
The challenge: push notifications are fire-and-forget
Unlike emails (which can embed tracking pixels), push notifications offer no built-in open tracking. Firebase Cloud Messaging only tells you:
- ✅ Was the notification delivered to the device?
- ❌ Did the user see it?
- ❌ Did the user tap it?
- ❌ When did they tap it?
Why this matters:
Without engagement data, you're flying blind:
- Can't measure ROI of campaigns
- Can't optimize send times
- Can't A/B test notification copy
- Can't identify disengaged users
Solution: track clicks when the app opens
My approach: When a user taps a notification and the app opens, send a tracking event to my backend.
High-level flow:
1. Backend sends notification → (includes job_id + member_seq in data payload)
2. User taps notification → App launches
3. App extracts job_id + member_seq from notification data
4. App calls POST /message/click → Backend records click
5. Later: Backend calculates open_rate = clicks / successful_sends
Key insight: Firebase includes the data payload when opening from a notification. I use this to pass tracking identifiers.
Implementation Part 1: Adding tracking data to notifications
First, I modified my send logic to include tracking identifiers:
// firebase.service.ts - sendConditionalNotifications
async sendConditionalNotifications(jobData: ConditionalNotificationParams) {
// ... DB query logic ...
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
const messages = chunk.map((token) => {
// ✅ Get member_seq for this token
const memberSeq = tokenToSeqMap.get(token);
return {
token,
notification: {
title: jobData.title,
body: jobData.content
},
data: {
// ✅ Tracking identifiers
job_id: jobData.jobId, // Campaign ID
member_seq: String(memberSeq), // User ID
// Other app routing data
screen_name: jobData.data.screen_name,
landing_url: jobData.data.landing_url,
// ...
},
};
});
// Send batch
const response = await sendEachWithRetry(messaging, messages, false);
// ... logging and progress tracking ...
}
}
Why include member_seq?
I need to know WHICH user clicked, not just that SOMEONE clicked. This allows:
- Deduplication (same user clicking multiple times)
- Per-user engagement analysis
- Segmentation (e.g., "iOS users click 2x more than Android")
Implementation Part 2: Click tracking API endpoint
I built a simple REST API that apps call when opening from a notification:
// firebase.controller.ts
@Post('click')
@Public() // ✅ No authentication required (called by mobile app)
@ApiOperation({
summary: '푸시 알림 클릭 기록',
description: `모바일 앱에서 푸시 알림 클릭 시 호출하는 API입니다.
주요 기능:
- 클릭 이벤트 기록 (job_id + member_seq)
- 중복 클릭 방지 (동일 회원의 동일 푸시는 1회만 기록)
- 클릭률(오픈율) 계산을 위한 데이터 수집`,
})
async recordClick(@Body() dto: RecordClickDto) {
try {
console.log(`[recordClick] 클릭 기록 요청 - Job: ${dto.job_id}, Member: ${dto.member_seq}`);
const recorded = await this.firebaseService.recordClick(dto);
return {
statusCode: 200,
message: recorded ? '클릭 기록 완료' : '이미 기록된 클릭',
data: { recorded },
};
} catch (error) {
console.error('[recordClick] 클릭 기록 실패:', error);
throw new HttpException(
{
statusCode: 500,
message: '클릭 기록에 실패했습니다.',
data: null,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
Request DTO:
// record-click.dto.ts
export class RecordClickDto {
@IsString()
job_id: string; // e.g., "conditional-a1b2c3d4-e5f6-7890-abcd-ef1234567890"
@IsInt()
member_seq: number; // User ID
@IsOptional()
@IsDateString()
clicked_at?: string; // Client can provide timestamp (or server uses current time)
}
Implementation Part 3: Database table with deduplication
I created a dedicated table for click events:
// push-notification-click-log.entity.ts
@Entity({ name: 'push_notification_click_log' })
@Index(['job_id', 'member_seq'], { unique: true }) // ✅ Prevent duplicate clicks
@Index(['job_id']) // For aggregation queries
@Index(['regdate']) // For hourly analysis
export class PushNotificationClickLog {
@PrimaryGeneratedColumn({ type: 'int' })
seq: number;
@Column({ type: 'varchar', length: 200 })
job_id: string;
@Column({ type: 'int' })
member_seq: number;
@CreateDateColumn({ type: 'datetime' })
regdate: Date; // Click timestamp
}
Key design: UNIQUE constraint on (job_id, member_seq)
This prevents double-counting if:
- User taps notification multiple times
- App crashes and retries the API call
- Race condition between multiple app instances
Service logic: deduplication and error handling
// firebase.service.ts
async recordClick(dto: RecordClickDto): Promise {
try {
const clickedAt = dto.clicked_at ? new Date(dto.clicked_at) : getKSTDate();
// ✅ Check if already recorded (application-level check)
const existing = await this.clickLogRepository.findOne({
where: {
job_id: dto.job_id,
member_seq: dto.member_seq,
},
});
if (existing) {
console.log(`[recordClick] 중복 클릭 무시 - Job: ${dto.job_id}, Member: ${dto.member_seq}`);
return false; // Already recorded
}
// ✅ Save new click
const clickLog = new PushNotificationClickLog({
job_id: dto.job_id,
member_seq: dto.member_seq,
regdate: clickedAt,
});
await this.clickLogRepository.save(clickLog);
console.log(`[recordClick] ✅ 클릭 기록 완료 - Job: ${dto.job_id}, Member: ${dto.member_seq}`);
return true; // Newly recorded
} catch (error) {
// ✅ Database-level duplicate (race condition)
if (error.code === 'ER_DUP_ENTRY' || error.number === 2627) {
console.log(`[recordClick] 중복 클릭 감지 (DB 제약) - Job: ${dto.job_id}`);
return false;
}
console.error(`[recordClick] 클릭 기록 실패:`, error);
throw error;
}
}
Two-level deduplication:
-
Application-level: Check
findOnebefore insert (fast path) - Database-level: UNIQUE constraint catches race conditions (safety net)
This is important because mobile apps can have:
- Slow networks (retry logic)
- Background/foreground transitions (duplicate events)
- Multiple processes (race conditions)
Calculating engagement metrics
Now the fun part: analyzing the data. I built a comprehensive stats API:
// firebase.service.ts
async getClickStats(jobId: string): Promise {
// ===== 1. Send statistics =====
const sendStats = await this.pushNotificationLog
.createQueryBuilder('log')
.select([
'COUNT(*) AS total_sent',
'SUM(CASE WHEN log.is_success = 1 THEN 1 ELSE 0 END) AS success_count',
'SUM(CASE WHEN log.is_success = 0 THEN 1 ELSE 0 END) AS failure_count',
"SUM(CASE WHEN log.error_type = 'invalid_token' THEN 1 ELSE 0 END) AS invalid_token_count",
"SUM(CASE WHEN log.error_type = 'temporary' THEN 1 ELSE 0 END) AS temporary_error_count",
'MIN(log.sent_at) AS first_sent_at', // For time-to-click calculation
])
.where('log.job_id = :jobId', { jobId })
.getRawOne();
const totalSent = parseInt(sendStats?.total_sent || '0');
const successCount = parseInt(sendStats?.success_count || '0');
const invalidTokenCount = parseInt(sendStats?.invalid_token_count || '0');
const temporaryErrorCount = parseInt(sendStats?.temporary_error_count || '0');
const firstSentAt = sendStats?.first_sent_at;
// ===== 2. Click statistics =====
const clickStats = await this.clickLogRepository
.createQueryBuilder('click')
.select([
'COUNT(DISTINCT click.member_seq) AS total_clicks', // ✅ DISTINCT for deduplication
'MIN(click.regdate) AS first_click_at',
'MAX(click.regdate) AS last_click_at',
])
.where('click.job_id = :jobId', { jobId })
.getRawOne();
const totalClicks = parseInt(clickStats?.total_clicks || '0');
// ===== 3. Key metrics =====
// ✅ Main metric: Open rate (clicks / successful sends)
const openRate = successCount > 0
? parseFloat(((totalClicks / successCount) * 100).toFixed(2))
: 0;
// ✅ Potential open rate (clicks / (success + temporary errors))
const potentialReachable = successCount + temporaryErrorCount;
const potentialOpenRate = potentialReachable > 0
? parseFloat(((totalClicks / potentialReachable) * 100).toFixed(2))
: 0;
// Overall click rate (reference only)
const overallClickRate = totalSent > 0
? parseFloat(((totalClicks / totalSent) * 100).toFixed(2))
: 0;
// Delivery rate (from Part 5)
const deliveryRate = totalSent > 0
? parseFloat(((potentialReachable / totalSent) * 100).toFixed(2))
: 0;
// ===== 4. Time analysis =====
let firstClickDelayMinutes: number | undefined;
if (firstSentAt && clickStats.first_click_at) {
const delayMs = new Date(clickStats.first_click_at).getTime() - new Date(firstSentAt).getTime();
if (delayMs > 0) {
firstClickDelayMinutes = parseFloat((delayMs / 60000).toFixed(2));
}
}
// Average time-to-click
let avgTimeToClickMinutes: number | undefined;
if (firstSentAt && totalClicks > 0) {
const avgClickTime = await this.clickLogRepository
.createQueryBuilder('click')
.select('AVG(TIMESTAMPDIFF(MINUTE, :sentAt, click.regdate)) AS avg_minutes')
.where('click.job_id = :jobId', { jobId })
.setParameter('sentAt', firstSentAt)
.getRawOne();
avgTimeToClickMinutes = avgClickTime?.avg_minutes
? parseFloat(parseFloat(avgClickTime.avg_minutes).toFixed(2))
: undefined;
}
// ===== 5. Hourly distribution =====
const hourlyDistribution = await this.clickLogRepository
.createQueryBuilder('click')
.select([
'HOUR(click.regdate) AS hour',
'COUNT(*) AS clicks',
])
.where('click.job_id = :jobId', { jobId })
.groupBy('HOUR(click.regdate)')
.orderBy('hour', 'ASC')
.getRawMany();
return {
job_id: jobId,
// Send statistics
total_sent: totalSent,
success_count: successCount,
failure_count: totalSent - successCount,
invalid_token_count: invalidTokenCount,
temporary_error_count: temporaryErrorCount,
// Click statistics
total_clicks: totalClicks,
// ✅ Key engagement metrics
open_rate: openRate, // Main metric
potential_open_rate: potentialOpenRate,
overall_click_rate: overallClickRate,
// Delivery metrics
delivery_rate: deliveryRate,
success_rate: parseFloat(((successCount / totalSent) * 100).toFixed(2)),
// Time analysis
first_click_delay_minutes: firstClickDelayMinutes,
avg_time_to_click_minutes: avgTimeToClickMinutes,
// Hourly distribution
hourly_distribution: hourlyDistribution.map(row => ({
hour: parseInt(row.hour),
clicks: parseInt(row.clicks),
})),
};
}
Example API response
GET /message/conditional-sends/conditional-abc-123/click-stats
{
"statusCode": 200,
"message": "클릭률 통계 조회 성공",
"data": {
"job_id": "conditional-abc-123",
// Send statistics
"total_sent": 500000,
"success_count": 410000,
"failure_count": 90000,
"invalid_token_count": 75000,
"temporary_error_count": 15000,
// Click statistics
"total_clicks": 61500,
// ✅ Key metrics
"open_rate": 15.0, // 61,500 / 410,000 = 15%
"potential_open_rate": 14.5, // 61,500 / 425,000 = 14.5%
"overall_click_rate": 12.3, // 61,500 / 500,000 = 12.3%
// Delivery metrics
"delivery_rate": 85.0, // 425,000 / 500,000 = 85%
"success_rate": 82.0, // 410,000 / 500,000 = 82%
// Time analysis
"first_click_delay_minutes": 0.5, // First click 30 seconds after send
"avg_time_to_click_minutes": 12.3, // Average: 12 minutes
// Hourly distribution
"hourly_distribution": [
{ "hour": 9, "clicks": 8200 }, // 9 AM: Peak engagement
{ "hour": 10, "clicks": 7100 },
{ "hour": 11, "clicks": 5900 },
{ "hour": 12, "clicks": 4200 }, // Noon: Lunch break
{ "hour": 13, "clicks": 3100 },
{ "hour": 14, "clicks": 2800 },
// ... more hours
]
}
}
Understanding the metrics
1. Open Rate (Main Metric)
open_rate = (total_clicks / success_count) * 100
= (61,500 / 410,000) * 100
= 15.0%
This is my primary engagement metric. It tells me: "Of all notifications that successfully reached devices, what percentage did users actually click?"
Why not include failures? Invalid tokens never reached a device, so they can't be clicked. Including them would artificially deflate the open rate.
2. Potential Open Rate
potential_open_rate = (total_clicks / (success + temporary)) * 100
= (61,500 / 425,000) * 100
= 14.5%
This includes temporary errors that might have succeeded on retry. Useful for predicting what the open rate COULD be if I fixed transient issues.
3. Overall Click Rate (Reference)
overall_click_rate = (total_clicks / total_sent) * 100
= (61,500 / 500,000) * 100
= 12.3%
This includes all sends (even invalid tokens). Useful for reporting to non-technical stakeholders who just care about the total campaign size.
Mobile app integration
iOS (Swift):
// AppDelegate.swift
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Extract tracking data
guard let jobId = userInfo["job_id"] as? String,
let memberSeqStr = userInfo["member_seq"] as? String,
let memberSeq = Int(memberSeqStr) else {
completionHandler(.noData)
return
}
// Record click
let clickData: [String: Any] = [
"job_id": jobId,
"member_seq": memberSeq,
"clicked_at": ISO8601DateFormatter().string(from: Date())
]
// Send to backend (async)
APIClient.shared.post(
url: "https://messaging.goodtv.co.kr/message/click",
body: clickData
) { result in
switch result {
case .success:
print("✅ Click recorded: \(jobId)")
case .failure(let error):
print("⚠️ Click recording failed: \(error)")
}
completionHandler(.newData)
}
}
Android (Kotlin):
// MyFirebaseMessagingService.kt
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val jobId = remoteMessage.data["job_id"]
val memberSeq = remoteMessage.data["member_seq"]?.toIntOrNull()
if (jobId != null && memberSeq != null) {
// Record click when notification is opened
recordClick(jobId, memberSeq)
}
// Show notification
showNotification(remoteMessage)
}
private fun recordClick(jobId: String, memberSeq: Int) {
val clickData = mapOf(
"job_id" to jobId,
"member_seq" to memberSeq,
"clicked_at" to SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date())
)
CoroutineScope(Dispatchers.IO).launch {
try {
apiClient.post("https://messaging.goodtv.co.kr/message/click", clickData)
Log.d(TAG, "✅ Click recorded: $jobId")
} catch (e: Exception) {
Log.w(TAG, "⚠️ Click recording failed: ${e.message}")
}
}
}
Real production insights (6 months of data)
After implementing click tracking, I analyzed 200+ campaigns:
Overall averages:
- Open rate: 12-18% (industry standard: 10-15%)
- First click: 30 seconds after send
- Average time-to-click: 15 minutes
- Peak engagement: 9-10 AM (morning commute)
Surprising findings:
1. Time-of-day matters more than day-of-week
I tested sending at different times:
| Send Time | Open Rate | Avg Time-to-Click |
|---|---|---|
| 6 AM | 8.2% | 45 min |
| 9 AM | 18.3% | 12 min |
| 12 PM | 11.5% | 28 min |
| 3 PM | 9.7% | 35 min |
| 6 PM | 14.2% | 18 min |
| 9 PM | 10.1% | 52 min |
Winner: 9 AM (commute time) - 18.3% open rate, 12-minute average click time.
2. Push notification copy length matters
| Title Length | Body Length | Open Rate |
|---|---|---|
| <20 chars | <50 chars | 16.2% |
| 20-40 chars | 50-100 chars | 14.8% |
| >40 chars | >100 chars | 9.5% |
Takeaway: Keep it short. Titles under 20 characters perform 70% better than long titles.
3. iOS vs Android engagement
// Query by platform
const iosOpenRate = await this.getOpenRateByPlatform(jobId, 'ios');
const androidOpenRate = await this.getOpenRateByPlatform(jobId, 'android');
// Results (average across 200 campaigns):
// iOS: 17.2% open rate
// Android: 13.1% open rate
iOS users click notifications 31% more than Android users. Why?
- Notification prominence (iOS notifications are larger)
- App badge culture (iOS users check apps more frequently)
- Demographic differences (iOS users in my app are older, more engaged)
4. Follow-up notifications have diminishing returns
I tested sending follow-up notifications to non-clickers:
| Attempt | Open Rate | Cumulative Open Rate |
|---|---|---|
| 1st | 15.2% | 15.2% |
| 2nd | 4.8% | 19.1% |
| 3rd | 1.2% | 20.0% |
Takeaway: Second attempts add 25% more opens. Third attempts are barely worth it.
5. Content type affects engagement
| Notification Type | Open Rate | Avg Time-to-Click |
|---|---|---|
| Daily Bible verse | 22.1% | 8 min |
| Event announcement | 18.3% | 15 min |
| App feature update | 9.2% | 45 min |
| Generic reminder | 6.5% | 120 min |
Takeaway: Content users explicitly signed up for (daily verses) performs 3x better than marketing messages.
Edge cases and gotchas
Gotcha 1: Background vs foreground clicks
Problem: iOS and Android handle notifications differently based on app state.
iOS:
- App in foreground:
userNotificationCenter(_:willPresent:)fires - App in background:
userNotificationCenter(_:didReceive:)fires - App terminated:
application(_:didFinishLaunchingWithOptions:)fires
Android:
- App in any state:
onMessageReceived()fires consistently
Solution: Track clicks in all handlers, rely on database UNIQUE constraint to prevent duplicates.
Gotcha 2: Delayed clicks (user opens app later)
Problem: User sees notification, doesn't tap it, opens app 2 hours later through home screen. Should I count this as a "click"?
Answer: No. I only count direct taps on the notification. This is intentional:
- Reflects true notification effectiveness
- Matches industry standards
- Prevents false positives (user might have forgotten about the notification)
Gotcha 3: Network failures during click recording
Problem: User taps notification, app opens, but network is offline. Click never recorded.
Solution: Implement client-side queue with retry:
// Android example
class ClickQueue {
private val pendingClicks = mutableListOf()
fun enqueue(jobId: String, memberSeq: Int) {
pendingClicks.add(ClickEvent(jobId, memberSeq, System.currentTimeMillis()))
tryFlush()
}
private fun tryFlush() {
if (!isNetworkAvailable()) return
pendingClicks.toList().forEach { event ->
try {
apiClient.post("/message/click", event)
pendingClicks.remove(event)
} catch (e: Exception) {
// Keep in queue for next attempt
}
}
}
}
In production, this catches ~2% of clicks that would otherwise be lost to network issues.
Gotcha 4: Multiple devices per user
Problem: User has app installed on phone and tablet. Taps notification on phone, then later on tablet. Counted twice?
Answer: Yes, and that's intentional. Each device represents a separate engagement opportunity. However, I can adjust the query:
// Option A: Count unique users (current implementation)
SELECT COUNT(DISTINCT member_seq) AS total_clicks FROM push_notification_click_log;
// Option B: Count all clicks (including multi-device)
SELECT COUNT(*) AS total_clicks FROM push_notification_click_log;
I chose Option A (unique users) because it reflects unique people who engaged, not total taps.
Cost and performance considerations
Database growth:
My push_notification_click_log table grows at:
- ~15% open rate × 500K sends = 75K rows per campaign
- 50 campaigns/month = 3.75M rows/month
- 45M rows/year
Storage: ~100 bytes/row × 45M = 4.5 GB/year (negligible)
Index overhead: UNIQUE constraint adds ~5% write overhead (acceptable)
Query performance:
-- Aggregation query (used in getClickStats)
SELECT
COUNT(DISTINCT member_seq) AS total_clicks,
HOUR(regdate) AS hour
FROM push_notification_click_log
WHERE job_id = 'conditional-abc-123'
GROUP BY HOUR(regdate);
- With proper indexes: <50ms for 100K clicks
- Without indexes: >5 seconds (unacceptable)
Optimization tip: If click tracking slows down sends, make the API call async:
// ✅ Don't block notification display
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Show notification immediately
showNotification(remoteMessage)
// Record click asynchronously (don't block)
CoroutineScope(Dispatchers.IO).launch {
recordClick(jobId, memberSeq)
}
}
Combining with dry-run validation (Part 5)
One powerful use case: predict open rates BEFORE sending.
// Step 1: Dry-run validation on 10K sample
const dryRunJobId = 'dryrun-campaign-123';
await sendConditionalNotifications({ ...params, isDryRun: true, limit: 10000 });
const deliveryStats = await getDeliveryStats(dryRunJobId);
// deliveryRate: 85% (425K reachable out of 500K)
// Step 2: Use historical open rates to predict engagement
const historicalOpenRate = 15.2; // From past campaigns
const predictedClicks = 500000 * (deliveryStats.deliveryRate / 100) * (historicalOpenRate / 100);
console.log(`
Prediction for 500K campaign:
- Estimated reachable: ${500000 * 0.85} = 425,000 devices
- Expected open rate: 15.2%
- Predicted clicks: ${Math.round(predictedClicks)} = 64,600 clicks
`);
// Step 3: Real send
const productionJobId = 'production-campaign-123';
await sendConditionalNotifications({ ...params, isDryRun: false, limit: 500000 });
// Step 4: Compare prediction vs reality
const actualStats = await getClickStats(productionJobId);
console.log(`
Actual results:
- Delivered: ${actualStats.success_count}
- Clicks: ${actualStats.total_clicks}
- Open rate: ${actualStats.open_rate}%
Prediction accuracy:
- Delivery: ${Math.abs(425000 - actualStats.success_count)} difference
- Clicks: ${Math.abs(64600 - actualStats.total_clicks)} difference
`);
In practice, my predictions are accurate within ±10% for delivery and ±15% for open rate.
Visualizing the data (bonus: dashboard)
I built a simple dashboard using this data:
// Dashboard API endpoint
@Get('dashboard/engagement-overview')
async getEngagementOverview(@Query('days') days: number = 30) {
const campaigns = await this.getRecentCampaigns(days);
const summary = {
totalCampaigns: campaigns.length,
totalSent: 0,
totalClicks: 0,
avgOpenRate: 0,
bestPerforming: null,
worstPerforming: null,
hourlyTrends: [],
};
for (const campaign of campaigns) {
const stats = await this.getClickStats(campaign.job_id);
summary.totalSent += stats.total_sent;
summary.totalClicks += stats.total_clicks;
// Track best/worst
if (!summary.bestPerforming || stats.open_rate > summary.bestPerforming.open_rate) {
summary.bestPerforming = { job_id: campaign.job_id, open_rate: stats.open_rate };
}
if (!summary.worstPerforming || stats.open_rate < summary.worstPerforming.open_rate) {
summary.worstPerforming = { job_id: campaign.job_id, open_rate: stats.open_rate };
}
}
summary.avgOpenRate = (summary.totalClicks / summary.totalSent) * 100;
return summary;
}
Example dashboard output:
📊 Last 30 Days Engagement Overview
====================================
Total Campaigns: 42
Total Sent: 18.5M notifications
Total Clicks: 2.8M
Average Open Rate: 15.1%
Best Performing:
Job: conditional-dec-holiday-promo
Open Rate: 24.3%
Worst Performing:
Job: conditional-app-update-reminder
Open Rate: 6.2%
Peak Engagement Hours:
9 AM: 312K clicks
10 AM: 287K clicks
8 AM: 245K clicks
Key takeaways
1. Click tracking is simpler than you think
- Add tracking IDs to notification data
- Record clicks when app opens
- Use UNIQUE constraint for deduplication
2. Open rate is the metric that matters
- Not delivery rate (that's Part 5)
- Not total clicks (inflated by duplicates)
- Clicks / Successful Sends = true engagement
3. Timing is everything
- 9 AM: 18.3% open rate (best)
- 6 AM: 8.2% open rate (worst)
- That's a 2.2x difference just from timing
4. Short and sweet wins
- Titles <20 chars: 16.2% open rate
- Titles >40 chars: 9.5% open rate
- 70% improvement from brevity
5. iOS > Android (for engagement)
- iOS: 17.2% open rate
- Android: 13.1% open rate
- Consider separate strategies per platform
If you're sending push notifications at scale, click tracking is non-negotiable. It transforms notifications from "spray and pray" to data-driven engagement. My open rates improved 40% over 6 months just by optimizing send times and notification copy based on this data.
In my next post, I'll show you how I built a real-time analytics dashboard that combines all these metrics (delivery rate, open rate, hourly trends) into a single view that updates every 2 seconds.
Top comments (0)