DEV Community

Cover image for Measuring Push Notification Engagement: Tracking Open Rates for 500K+ Campaigns
Sangwoo Lee
Sangwoo Lee

Posted on

Measuring Push Notification Engagement: Tracking Open Rates for 500K+ Campaigns

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
Enter fullscreen mode Exit fullscreen mode

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 ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two-level deduplication:

  1. Application-level: Check findOne before insert (fast path)
  2. 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),
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

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
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Understanding the metrics

1. Open Rate (Main Metric)

open_rate = (total_clicks / success_count) * 100
          = (61,500 / 410,000) * 100
          = 15.0%
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode

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%
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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}")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
`);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)