System Design Case Study

Design notification analytics — open rates, click-through, bounce handling, feedback loops

🎯 Design notification analytics — track delivery metrics (sent, delivered, opened, clicked, bounced, unsubscribed), compute per-template effectiveness, handle ISP feedback loops, and enable A/B comparison at 5B notifications/day
Concepts Involved

Problem Statement

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?

Scope: Tracking pixel infrastructure, redirect URL click tracking, bounce/complaint processing from ISPs, real-time per-template metric aggregation, A/B effectiveness comparison dashboards. Not covering notification delivery mechanics or content generation.
5B/day
notifications tracked
across push, email, SMS, in-app
6
delivery states tracked
sent→delivered→opened→clicked→bounced→unsubscribed
30s
metric freshness
real-time aggregation latency
500K
tracking pixel requests/sec
peak email open tracking load

Functional Requirements

What the analytics system must do · core tracking behaviours

Must Have (Core)

1. Email open tracking · 1×1 transparent pixel embedded in email HTML, logs open on fetch
2. Click tracking · redirect URLs that log click then 302 redirect to destination
3. Bounce processing · handle hard bounces (invalid email) and soft bounces (mailbox full)
4. ISP feedback loops · process spam complaint reports (ARF format) from ISPs
5. Per-template metrics · open rate, CTR, bounce rate, unsubscribe rate per template
6. Real-time dashboards · live delivery funnel visualization with <30s freshness

Out of Scope (this design)

Notification delivery infrastructure (sending mechanics)
Content generation and template authoring
User-facing analytics (creator insights, engagement stats)
ML-based send-time optimization
Deliverability scoring (domain reputation management)

Non-Functional Requirements

Quality constraints shaping the analytics architecture

PropertyTargetWhy It Matters / Design Impact
Tracking Pixel Latency<50ms responsePixel request must return 1×1 GIF immediately. Log asynchronously. Never block email rendering.
Click Redirect Latency<100ms to redirectUser should never perceive click tracking. Log click, 302 redirect within 100ms. CDN-edge handling.
Event Volume5B events/day (58K/sec)Each notification generates 2-4 tracking events (sent, delivered, opened, clicked). Total ~15B events/day.
Metric Freshness<30sDashboard shows near-real-time delivery rates. Stream processing with micro-batch aggregation.
Data Retention90 days hot, 2 years coldHot data (last 90 days) for real-time queries. Cold data (2 years) in columnar storage for historical analysis.
Bounce Processing<5 min from ISP reportHard bounces must suppress future sends within 5 minutes. Prevents further reputation damage.
Accuracy±0.1% on aggregatesApproximate counting (HyperLogLog) acceptable for unique opens. Exact counts for sent/bounced.
Key tension: Tracking Accuracy vs. User Privacy. Email open tracking relies on pixel loads — but Apple Mail Privacy Protection pre-fetches all images, inflating open rates. Solution: weight Apple Mail opens at 0.5x and use click-through as more reliable engagement signal.

Scale Estimation

Derive infrastructure sizing from given constraints. Show this reasoning in an interview.

Given: 5B notifications/day · 40% email · 25% open rate · 5% CTR · 2% bounce rate
StepWhat to DeriveCalculationResultDesign 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.
Interview tip: The tracking pixel is served from CDN edge (Cloudflare Worker / CloudFront Lambda@Edge). It returns a cached 1×1 GIF immediately and logs the open event asynchronously. The pixel URL encodes notification_id as a path parameter — no query strings (some email clients strip them).

APIs

Tracking endpoints (pixel/click) and analytics query APIs

GET /t/o/{notification_id}.gif (Tracking Pixel — CDN Edge)
1×1 transparent GIF that logs email open event
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/{notification_id}/{link_id} (Click Redirect)
Log click event then 302 redirect to destination URL
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" }
POST /v1/webhooks/bounce (ISP Bounce Webhook)
Receive bounce/complaint notifications from email providers (SendGrid, SES)
{
  "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/{template_id}
Query aggregated metrics for a notification template
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 }
  }
}

Data Model

Event store, aggregation tables, and suppression lists

tracking_events (Kafka → ClickHouse)

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;

template_metrics_realtime (Redis — pre-aggregated counters)

// 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)

suppression_list (Redis + DynamoDB)

// 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
}

delivery_funnel (ClickHouse — materialized view)

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;

Architecture Overview

Tracking Endpoints (Edge) → Kafka → Stream Processor → ClickHouse + Redis Counters → Dashboard

TRACKING ENDPOINTS · CDN Edge (Cloudflare Workers / Lambda@Edge) Tracking Pixel Server GET /t/o/{id}.gif → 1×1 GIF + log event Click Redirect Server GET /t/c/{id}/{link} → 302 + log click Delivery Webhooks Provider callbacks (sent/delivered) ISP Feedback (ARF) Bounce + complaint reports KAFKA EVENT BUS · 256 partitions · 7-day retention · ~93K events/sec Topic: tracking-events Topic: bounce-events Topic: complaint-events Topic: delivery-receipts STREAM PROCESSORS · Real-time aggregation + suppression Metrics Aggregator (Flink) Per-template counters · 30s windows → Redis counters + ClickHouse Bounce Processor Hard bounce → add to suppression list Soft bounce → retry count tracking Complaint Processor ARF report → auto-unsubscribe user Track per-domain complaint rate A/B Per-variant metrics STORAGE · Hot metrics (Redis) + Analytical store (ClickHouse) + Cold (S3) Redis (Real-Time Counters) Per-template per-hour INCR · 7-day TTL ClickHouse (90-day hot) Columnar · fast aggregation queries S3 + Parquet (cold) 2-year retention · Athena queries Suppression List Redis SET + DynamoDB ANALYTICS DASHBOARD · Real-time delivery funnel + A/B comparison Delivery Funnel View Sent → Delivered → Opened → Clicked Per template · per channel · per hour A/B Test Comparison Variant v1 vs v2: open rate, CTR Statistical significance calculator Health Alerts Bounce rate spike detection Complaint rate threshold alert Domain Reputation Per-ISP delivery rates Gmail / Yahoo / Outlook KEY INSIGHT Bounce processing must suppress within 5 minutes. Sending to hard- bounced emails damages domain rep.

Key Design Decisions

Tradeoffs made in this architecture and their rationale

DecisionOptions ConsideredChosenWhy
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.
Why tracking pixels must be served from CDN edge: 500M pixel loads/day peaks at 500K/sec during morning email opens. Origin servers can't handle this. CDN edge (Cloudflare Workers) returns cached 1×1 GIF instantly, logs the open event to a local buffer, then async-flushes to Kafka. Zero origin load for the hot path.
Click redirect URL design: /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.
ISP feedback loop (FBL) processing: When a user marks email as spam, the ISP sends an ARF (Abuse Reporting Format) report to the sender's FBL address. You MUST process these within minutes: auto-unsubscribe the user, add to suppression list. Complaint rates above 0.1% trigger ISP throttling. Above 0.3% → blacklisting.
A/B testing with tracking: The tracking pixel URL includes variant info: /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).

Resilience & Edge Cases

How the analytics system handles failures and edge cases

ScenarioProblemSolutionRecovery
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

Interview Cheat Sheet

5 key points to nail a notification analytics interview

1. Tracking Pixel at CDN Edge
Embed a 1×1 transparent GIF in every email. URL encodes notification_id + variant. CDN edge returns the same 43-byte GIF for all requests (zero origin load), but logs the request metadata (notification_id, timestamp, user-agent) asynchronously. Buffer locally, batch-flush to Kafka every 1 second. Handles 500K/sec at peak.
2. Click Redirect with 302 (Not 301)
Rewrite all email links to redirect URLs: /t/c/{notification_id}/{link_id}. On click: log event, lookup destination from link_id mapping, return 302 redirect. Use 302 (temporary) not 301 (permanent) — browsers cache 301 and skip the tracker on subsequent clicks. Sub-100ms total latency.
3. Bounce Suppression within 5 Minutes
Hard bounces (550 User not found) must immediately add the email to a suppression list. The notification service checks this list BEFORE sending. Every email to a hard-bounced address damages your domain reputation with that ISP. Complaint rates above 0.1% trigger throttling. Stream process bounce events in real-time.
4. Pre-Aggregated Redis Counters for Dashboards
Don't query ClickHouse for every dashboard refresh. Stream processor INCRs Redis counters per-template per-hour per-metric during event processing. Dashboard reads O(1) from Redis. Counters have 7-day TTL. Historical queries fall through to ClickHouse. This decouples dashboard latency from analytical query performance.
5. Apple Mail Privacy Adjustment
Apple Mail Privacy Protection (since iOS 15) pre-fetches ALL images including tracking pixels, making open tracking unreliable for Apple Mail users (~45% of email clients). Solution: detect Apple Mail UA, weight those opens at 0.5x for adjusted metrics. Emphasize click-through rate as the more reliable engagement metric. Report both raw and adjusted open rates.