The Push Notification Bottleneck

Mobile game retention depends on timely, relevant push notifications. Yet most game studios treat push as an afterthought -- sending batch blasts to every active user at a fixed time each day. The result: high opt-out rates, low CTR, and missed revenue opportunities. CCFish needed a better approach: personalized, automated, and serverless.

This post walks through how CCFish built an automated push notification engine on Cloudflare Workers and D1. The system handles player segmentation, delivery scheduling, A/B testing, and performance analytics -- all without provisioning a single server.

Architecture Overview

The push notification system follows an event-driven architecture on Cloudflare Workers:

- **Event triggers** capture player actions (level-up, purchase, 3-day inactive, referral sent)

- **Segmentation engine** classifies each player into behavioral cohorts using D1 query results

- **Campaign scheduler** determines optimal send time based on each player's historical engagement patterns

- **Delivery pipeline** pushes notifications via Firebase Cloud Messaging with rate-limiting

- **Analytics collector** tracks opens, conversions, and opt-out rates back into D1

The entire pipeline runs as a cron-triggered Worker or on-demand via webhook. No queue servers, no message brokers, no dedicated infrastructure.

Step 1: Player Segmentation via D1 Queries

The first challenge is grouping players into actionable segments. CCFish stores player events in D1 tables:

```sql

-- Find players at risk of churn (no login in 5 days, previously active 20+ sessions)

SELECT p.id, p.fcm_token, p.last_login, p.total_purchases

FROM players p

WHERE p.last_login < datetime('now', '-5 days')

AND p.total_sessions > 20

AND p.push_opt_out = 0

AND p.fcm_token IS NOT NULL;

```

This query runs inside a D1-prepared statement on a cron Worker. Each segment has its own query template with configurable parameters (churn window, session threshold, purchase minimum). The system supports six segments: Churn Risk, High Spender, New User, Power User, Lapsed Purchaser, and Referral Trigger.

Step 2: Optimal Send Time Calculation

Batch blasts fail because players engage at different times. CCFish's system calculates each player's optimal send window from historical session data:

```sql

-- Find the 3-hour window when this player has most sessions

SELECT CAST(strftime('%H', created_at) AS INTEGER) as hour,

COUNT(*) as sessions

FROM events

WHERE player_id = ?

AND type = 'session_start'

GROUP BY hour

ORDER BY sessions DESC

LIMIT 1;

```

The result maps to a delivery window: hour 8-11 for morning players, 18-21 for evening players, and 13-16 for afternoon players. Notifications queue in a KV-backed deduplication store and release on schedule.

Step 3: A/B Testing Messaging

Each campaign defines up to 4 message variants. The Worker randomly assigns variants at a configurable ratio. Performance data flows back to D1:

```sql

INSERT INTO push_results (campaign_id, variant, sent, opened, converted)

VALUES (?, ?, 1, ?, ?)

ON CONFLICT(campaign_id, variant) DO UPDATE SET

sent = sent + 1,

opened = opened + ?,

converted = converted + ?;

```

After 1,000 sends per variant, the system automatically shifts 80% traffic to the winning variant. This adaptive optimization runs continuously without manual intervention.

Results

After 8 weeks of automated push campaigns:

- Push notification CTR: 4.2% (vs. 1.8% pre-automation)

- Churn rate reduction: 23% among targeted at-risk players

- Revenue per notification: $0.42 (vs. $0.11 for batch blasts)

- Opt-out rate: 1.2% monthly (industry average: 3-5%)

- Infrastructure cost: $0 (within Workers free tier)

Key Takeaways

- Serverless push automation removes the infrastructure barrier for indie game studios

- D1 analytics enable real-time segmentation without expensive data pipelines

- A/B testing at the notification level continuously improves engagement

- Optimal send time personalization doubles CTR compared to batch blasts

- The entire system runs on Cloudflare's free tier for most game volumes

Implementation Details: The Campaign Builder UI

While the core logic runs on Workers, CCFish also provides a campaign builder interface within the game's admin dashboard. This UI lets marketing managers configure campaigns without touching code:

- **Trigger selection**: Choose from 12 event types (purchase, level-up, referral, inactivity, etc.)

- **Segment filters**: AND/OR conditions on player properties (total spend, sessions, country, device)

- **Message composer**: Rich text editor with emoji support, deep links, and variable insertion

- **Schedule**: Immediate, time-delayed, or recurring (e.g., every Friday at 6 PM)

- **Budget cap**: Max notifications per campaign to prevent over-messaging

The admin panel communicates with the Worker through a REST API secured by API tokens. Campaign configurations are stored in D1's ec_post_meta table, keeping the admin experience responsive even during high-volume push campaigns.

Error Handling and Rate Limiting

Push notification delivery is inherently unreliable -- devices may be offline, FCM tokens may expire, or rate limits may be hit. The CCFish pipeline handles these gracefully:

- **Token rotation**: Inactive FCM tokens are flagged after 3 consecutive delivery failures

- **Rate limiting**: Maximum 200 notifications per minute per device token

- **Retry queue**: Failed deliveries retry up to 3 times with exponential backoff (5min, 15min, 60min)

- **Dead letter**: Notifications that fail all retries are logged to a D1 dead-letter table for manual review

The rate limiter uses Cloudflare Workers' single-flight pattern with KV for counter storage, ensuring accuracy even under concurrent execution.

Measuring Campaign ROI

Every push notification campaign tracks the full attribution funnel:

```sql

SELECT

campaign_id,

COUNT(DISTINCT player_id) as reached,

SUM(CASE WHEN opened = 1 THEN 1 ELSE 0 END) as opens,

SUM(CASE WHEN converted = 1 THEN 1 ELSE 0 END) as conversions,

SUM(CASE WHEN converted = 1 THEN purchase_value ELSE 0 END) as revenue

FROM push_analytics

WHERE sent_at > datetime('now', '-30 days')

GROUP BY campaign_id;

```

This query powers a live dashboard that shows cost-per-conversion, revenue attribution, and projected monthly impact. The entire analytics stack runs on D1 with sub-200ms query times, even when analyzing 100,000+ notification events.