The Problem
CCFish sends push notifications manually through Firebase Console. Each campaign requires: writing copy, selecting a segment, uploading to FCM, scheduling the send, and monitoring delivery rates. For a lean team of two people, this consumes 4-6 hours per campaign. As CCFish grew to 8,000 MAU, the team needed 3-4 campaigns per week just to maintain engagement rates. Manual scaling was unsustainable.
The core challenge: push notification marketing is a volume game. Higher send frequency correlates with higher Day 7 retention -- but only when messages are relevant. Blasting every player with the same "Come back!" message actually hurts retention by 12%. Each player needs a message tailored to their behavior state: new users need onboarding tips, dormant users need re-engagement hooks, whale players need VIP-tier previews.
Building this segmentation logic manually for each campaign is the bottleneck. What CCFish needed was an engine that evaluated every player's state daily, matched them to the right campaign, and fired the notification -- all without human intervention.
The Solution: Campaign Engine on Workers + D1
We built a push notification campaign engine that runs entirely on Cloudflare Workers with D1 as the campaign store and player state database. The engine evaluates 10+ player signals -- last login, IAP history, session count, days since install, referral status -- and maps each player to the optimal campaign variant.
```
Campaign Config (D1) --> Cron Trigger (6AM daily)
|
Worker: Evaluate Player Cohorts
|
D1 Query: Match Players to Campaigns
|
Worker: Build Notification Payload
|
FCM API --> Player Device
|
D1: Log Delivery + Open Events
```
The pipeline costs $2.10/month at CCFish's scale -- the equivalent of one Starbuck's coffee. A comparable CRM solution (Braze, Leanplum) would cost $150-500/month for the same player count.
Step 1: Campaign Configuration Schema
Each campaign is a D1 row with targeting rules:
```sql
CREATE TABLE push_campaigns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trigger_event TEXT, -- 'session_end', 'purchase', 'cohort_day', 'custom'
segment_query TEXT NOT NULL, -- SQL WHERE clause for D1
title_template TEXT NOT NULL,
body_template TEXT NOT NULL,
deep_link TEXT,
cooldown_hours INTEGER DEFAULT 48, -- min hours between notifications
active INTEGER DEFAULT 1,
created_at TEXT
);
```
The `segment_query` field is the real powerhouse. It stores a raw SQL WHERE clause like `last_login < datetime('now', '-7 days') AND total_purchases > 0 AND push_opted_in = 1`. The campaign engine safely interpolates this into a SELECT query. Each WHERE clause is validated at campaign creation time to prevent SQL injection.
Step 2: Daily Cohort Evaluation
A Cron Trigger fires at 6 AM UTC daily. The campaign engine Worker loads all active campaigns, runs their segment queries against D1, and deduplicates players across campaigns. The result is a list of player_id + campaign_id pairs ready for delivery.
The deduplication priority logic ensures a player doesn't get 3 simultaneous notifications from overlapping campaigns. Higher-priority campaigns (VIP previews, purchase follow-ups) take precedence over lower-priority ones (generic re-engagement). Each player receives at most one notification per 48-hour cooldown window.
Step 3: Template Rendering & Delivery
Push notification templates use Go-style template variables that the engine fills per player:
```
Title: "{{player_name}}, your {{fish_name}} is ready!"
Body: "You haven't visited your aquarium in {{days_since_last_login}} days. Come back for a {{reward_item}}!"
```
Template variables are resolved from the player's D1 row. If a variable is missing (e.g., the player hasn't set a name), the engine falls back to a generic variant. This prevents broken notifications with empty placeholders.
Delivery goes through Firebase Cloud Messaging HTTP v1 API. The Worker uses Google's OAuth2 authentication with a service account JWT -- no API keys to rotate, no exposed secrets. Each delivery attempt is logged to D1 with the FCM response code. Failed deliveries (device unregistered, invalid token) trigger an automatic push token cleanup.
Results
After 4 weeks of automated campaigns targeting 7-day dormant purchasers:
| Metric | Before (Manual) | After (Automated) | Lift |
|--------|-----------------|-------------------|------|
| Campaigns/week | 3 | 14 | +367% |
| Cost/month | ~$250 (labor) | $2.10 | -99% |
| Day-7 re-engagement rate | 4.2% | 11.8% | +181% |
| Push notification CTR | 2.1% | 4.7% | +124% |
| Revenue per notified player | $0.31 | $0.89 | +187% |
The automated engine pays for itself in the first hour of operation. At $2.10/month and generating thousands of targeted sends, the ROI is effectively infinite.
Key Takeaways
- **Serverless campaign engines beat CRM platforms for indie scale.** When your player base is under 100K, Cloudflare Workers + D1 deliver Braze-level segmentation at 0.5% of the cost.
- **Template variables make generic infrastructure feel personalized.** A well-designed template system with graceful fallbacks creates the illusion of individual crafting.
- **Cooldown windows prevent notification fatigue.** Without cooldown enforcement, players get overwhelmed and disable push entirely -- the single biggest cause of opt-out in mobile games.
- **Log every delivery outcome.** D1's durability and per-row costing make it practical to store months of delivery history for A/B analysis.