Design notification analytics — open rates, click-through, bounce handling, feedback loops
How do you track notification delivery metrics at 5B notifications/day — measuring sent, delivered, opened, clicked, bounced, and unsubscribed — using tracking pixels for email opens, redirect URLs for clicks, and ISP feedback loop processing for spam complaints?
What the analytics system must do · core tracking behaviours
Quality constraints shaping the analytics architecture
| Property | Target | Why It Matters / Design Impact |
|---|---|---|
| Tracking Pixel Latency | <50ms response | Pixel request must return 1×1 GIF immediately. Log asynchronously. Never block email rendering. |
| Click Redirect Latency | <100ms to redirect | User should never perceive click tracking. Log click, 302 redirect within 100ms. CDN-edge handling. |
| Event Volume | 5B events/day (58K/sec) | Each notification generates 2-4 tracking events (sent, delivered, opened, clicked). Total ~15B events/day. |
| Metric Freshness | <30s | Dashboard shows near-real-time delivery rates. Stream processing with micro-batch aggregation. |
| Data Retention | 90 days hot, 2 years cold | Hot data (last 90 days) for real-time queries. Cold data (2 years) in columnar storage for historical analysis. |
| Bounce Processing | <5 min from ISP report | Hard bounces must suppress future sends within 5 minutes. Prevents further reputation damage. |
| Accuracy | ±0.1% on aggregates | Approximate counting (HyperLogLog) acceptable for unique opens. Exact counts for sent/bounced. |
Derive infrastructure sizing from given constraints. Show this reasoning in an interview.
| Step | What to Derive | Calculation | Result | Design Decision |
|---|---|---|---|---|
| 1 | Email notifications/day | 5B × 40% | 2B emails/day | Primary channel for open/click tracking |
| 2 | Tracking pixel loads/day | 2B × 25% open rate | 500M pixel loads/day | ~5,800/sec avg, 500K/sec peak. CDN-edge pixel serving required. |
| 3 | Click redirect requests/day | 2B × 5% CTR | 100M clicks/day | ~1,157/sec avg. Redirect service at edge with async logging. |
| 4 | Bounce events/day | 2B × 2% bounce rate | 40M bounces/day | Must process and suppress within 5 minutes. Feed into suppression list. |
| 5 | Total tracking events/day | 5B sent + 500M opens + 100M clicks + 40M bounces + delivery receipts | ~8B events/day | Kafka: 256 partitions, 7-day retention for replay |
| 6 | Event storage/day | 8B events × 200 bytes avg | ~1.6TB/day | ClickHouse for hot queries (90 days). S3/Parquet for cold (2 years). |
| 7 | ISP feedback volume | 2B emails × 0.05% complaint rate | ~1M complaints/day | Process ARF reports. Auto-unsubscribe complainants. Track per-domain complaint rate. |
| 8 | Dashboard query load | ~100 templates × per-minute aggregation × 10 metrics | ~1,000 metric series | Pre-aggregated in Redis counters. Dashboard reads from Redis, not raw events. |
Tracking endpoints (pixel/click) and analytics query APIs
GET /t/o/n_abc123_v2.gif
Response: 200 OK
Headers:
Content-Type: image/gif
Content-Length: 43
Cache-Control: no-cache, no-store, must-revalidate
Body: [1x1 transparent GIF bytes]
Side effect: Emit event to Kafka
{ "type": "open", "notification_id": "n_abc123", "variant": "v2",
"timestamp": "2024-03-15T10:30:00Z", "user_agent": "...", "ip": "..." }
GET /t/c/n_abc123/link_cta_1
Response: 302 Found
Headers:
Location: https://example.com/product/123?utm_source=notification
Cache-Control: no-cache
Side effect: Emit event to Kafka
{ "type": "click", "notification_id": "n_abc123", "link_id": "link_cta_1",
"destination": "https://example.com/product/123",
"timestamp": "2024-03-15T10:31:00Z" }
{
"event_type": "bounce", // bounce | complaint | unsubscribe
"bounce_type": "hard", // hard | soft
"email": "user@example.com",
"notification_id": "n_abc123",
"provider": "sendgrid",
"reason": "550 User not found",
"timestamp": "2024-03-15T10:32:00Z"
}
Response: 200 OK { "processed": true, "suppression_added": true }
GET /v1/analytics/metrics/order_shipped?period=24h&channel=email
Response 200:
{
"template_id": "order_shipped",
"channel": "email",
"period": "24h",
"metrics": {
"sent": 2450000,
"delivered": 2401000, "delivery_rate": 0.98,
"opened": 612000, "open_rate": 0.255,
"clicked": 122000, "ctr": 0.05,
"bounced": 49000, "bounce_rate": 0.02,
"complained": 1200, "complaint_rate": 0.0005,
"unsubscribed": 4800, "unsub_rate": 0.002
},
"variants": {
"v1": { "open_rate": 0.24, "ctr": 0.045 },
"v2": { "open_rate": 0.27, "ctr": 0.055 }
}
}
Event store, aggregation tables, and suppression lists
CREATE TABLE tracking_events (
event_id String,
notification_id String,
user_id String,
template_id String,
channel Enum8('push'=1, 'email'=2, 'sms'=3, 'inapp'=4),
event_type Enum8('sent'=1, 'delivered'=2, 'opened'=3, 'clicked'=4, 'bounced'=5, 'complained'=6, 'unsubscribed'=7),
variant Nullable(String), -- A/B test variant
metadata String, -- JSON: user_agent, ip, link_id, bounce_reason
event_time DateTime64(3),
ingestion_time DateTime64(3)
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (template_id, channel, event_type, event_time)
TTL event_time + INTERVAL 90 DAY;
// Per-template, per-channel, per-hour counters
// Key pattern: metrics:{template_id}:{channel}:{hour_bucket}:{metric}
INCR metrics:order_shipped:email:2024031510:sent
INCR metrics:order_shipped:email:2024031510:delivered
INCR metrics:order_shipped:email:2024031510:opened
INCR metrics:order_shipped:email:2024031510:clicked
// Per-variant counters (for A/B testing)
INCR metrics:order_shipped:email:2024031510:v1:opened
INCR metrics:order_shipped:email:2024031510:v2:opened
EXPIRE metrics:order_shipped:email:2024031510:sent 604800 // 7-day TTL (hot window)
// Hard bounced emails — never send again
SADD suppression:hard_bounce "user@example.com" "invalid@gone.com"
// Complaint suppressions — auto-unsubscribe
SADD suppression:complaint "annoyed@user.com"
// Persistent store (DynamoDB)
{
"email": "user@example.com", // partition key
"reason": "hard_bounce", // hard_bounce | complaint | unsubscribe
"source": "sendgrid",
"notification_id": "n_abc123",
"suppressed_at": "2024-03-15T10:32:00Z",
"ttl": null // permanent for hard bounces
}
CREATE MATERIALIZED VIEW delivery_funnel
ENGINE = SummingMergeTree()
ORDER BY (template_id, channel, event_type, hour_bucket)
AS SELECT
template_id,
channel,
event_type,
toStartOfHour(event_time) AS hour_bucket,
count() AS event_count,
uniqHLL12(user_id) AS unique_users
FROM tracking_events
GROUP BY template_id, channel, event_type, hour_bucket;
Tracking Endpoints (Edge) → Kafka → Stream Processor → ClickHouse + Redis Counters → Dashboard
Tradeoffs made in this architecture and their rationale
| Decision | Options Considered | Chosen | Why |
|---|---|---|---|
| Open Tracking Method | Tracking pixel, email proxy, read receipts (not available for email) | 1×1 transparent tracking pixel | Industry standard. Embedded in email HTML. Loaded when email is opened (with image display). No alternative for email open tracking. |
| Click Tracking Method | Redirect URLs, JavaScript click handlers (not available in email), link decoration | Redirect URLs (302) | All email links rewritten to redirect URLs. Log click, then 302 to destination. Works universally. Sub-100ms perceived latency. |
| Analytics Store | PostgreSQL, Elasticsearch, ClickHouse, BigQuery | ClickHouse (hot) + S3/Parquet (cold) | ClickHouse: columnar, compression, fast aggregation on 1.6TB/day ingestion. 10x cheaper than BigQuery for this write-heavy workload. |
| Real-Time Dashboard | Query ClickHouse directly, pre-aggregated counters in Redis | Redis pre-aggregated counters | Dashboard refresh every 5s. Querying ClickHouse at this rate is expensive. Redis INCR during stream processing → O(1) dashboard reads. |
| Bounce Suppression Speed | Batch process hourly, real-time per bounce | Real-time: suppress within 5 minutes | Every email sent to a hard-bounced address damages domain reputation. ISPs track repeated bounces. Must suppress immediately. |
| Apple Privacy Handling | Count all opens equally, weight Apple opens, ignore Apple opens | Weight Apple Mail opens at 0.5x | Apple Mail Privacy Protection pre-fetches all images (inflating opens). Weighting at 0.5x approximates real engagement. Click-through is more reliable metric. |
/t/c/{notification_id}/{link_id} — no query parameters (some email clients strip them). The notification_id encodes template_id + user_id + variant for attribution. Link_id maps to a destination URL lookup table. 302 (not 301) prevents browsers from caching and skipping future tracking./t/o/n_abc123_v2.gif. The stream processor splits metrics by variant. After sufficient sample size (>10K sends per variant), compute statistical significance. Auto-promote winning variant at 95% confidence. Track open_rate AND ctr (not just opens).How the analytics system handles failures and edge cases
| Scenario | Problem | Solution | Recovery |
|---|---|---|---|
| Pixel Load Spike | Morning email blast causes 500K pixel requests/sec. | CDN edge handles load. Pixel GIF is same bytes for all requests (cacheable). Only the logging varies. Edge buffer + batch flush to Kafka every 1s. | CDN absorbs (infinite scale) |
| ClickHouse Ingestion Lag | ClickHouse falls behind on event ingestion (8B events/day). | Kafka retains 7 days. ClickHouse catches up from Kafka offset. Redis counters are unaffected (separate path). Dashboard stays fresh via Redis. | Auto-catch-up from Kafka |
| Duplicate Pixel Loads | Email client loads pixel multiple times (preview pane, re-open). | Deduplicate opens per notification_id: first open counts as "open event". Subsequent loads are counted as "re-opens" (separate metric). Use Redis SET for dedup. | Accurate unique open count |
| Apple Mail Privacy | Apple pre-fetches all images, inflating open rates by 30-40%. | Detect Apple Mail user-agent. Weight those opens at 0.5x. Report "adjusted open rate" alongside raw. Emphasize CTR as primary engagement metric. | Adjusted metrics in dashboard |
| Bounce Storm | Sending to purchased list → 20% bounce rate → ISP throttling. | Real-time bounce rate monitoring per campaign. Auto-pause campaign if bounce rate exceeds 5% in first 1000 sends. Alert operations team immediately. | <5 min auto-pause |
| Redis Counter Loss | Redis shard failure loses recent counter increments. | Redis counters are a cache of aggregated data (ClickHouse is source of truth). Rebuild counters from ClickHouse for affected time window. ~10 min recovery. | ~10 min rebuild from ClickHouse |
5 key points to nail a notification analytics interview