System design
Part 5 of 6 · Push NotificationsEnd-to-End Notification Analytics — Send, Delivery, Receipt & Engagement
Push without a funnel is flying blind. Senior interviews expect queued → accepted → delivered → displayed → opened → engaged, plus clock skew, attribution, privacy (no PII in payloads), and tracing notification_id across services. Provider accept is not 'the user saw it'.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
What you report to execs
Prefer
Stage-honest funnel joined on notification_id
Queued is intent. Accepted is provider HTTP success. Delivered/displayed are best-effort and often incomplete. Opened and engaged are first-party. Silent OS drops are inferred from cohort gaps, not pretended as 100% telemetry.
- Store provider_message_id beside nid at accept.
- Skew-tolerant windows (e.g. open within 1h of send).
- Show pipeline lag SLO so warehouse delay is not 'delivery is dying'.
Alternative
FCM 'delivered' as 'user saw it'
Overstates success. Missing nid on legacy payloads orphans opens. Collapse without a supersede rule double-counts or hides abandonment. PII in data payloads is a compliance incident.
- APNs delivery receipts are limited — not full device telemetry.
- Debounce open events on nid+device; idempotent upsert.
- Enforce schema at compose so nid is never optional.
Canonical funnel
Each arrow can drop. Interviews start at the missing stage, not at a pie chart.
- 1
queued
Intent persisted / on the outbound bus. This is the denominator you actually control. - 2
accepted
APNs/FCM HTTP success. Provider accepted — not device delivered. - 3
delivered / displayed
Device/OS ack when available; shade presented via SDK/OS hooks. Best-effort, often incomplete. - 4
opened
User tapped. First-party: nid in payload + app instrumentation. - 5
engaged
In-app action attributed to nid (purchase, reply, dismiss reason) inside the attribution window.
Overview
Push without a funnel is flying blind. Seniors name clock skew, attribution, privacy, and tracing — not just "we use FCM analytics."
You should be able to:
- Draw queued → engaged as a single-column join on
nid. - Say what you cannot see (silent OS drops).
- Point at SLIs for how you turn rates into error budgets — without copying that lesson.
What you can vs cannot see
| Signal | Strength | Lie to avoid |
|---|---|---|
| Provider accept | Strong | "Seen by a human" |
| APNs delivery receipts | Limited | Full device telemetry for all apps |
| FCM analytics aggregates | Useful | A substitute for a first-party funnel |
| Open / engage | First-party if nid + SDK | Opens without nid |
| Silent drops | Often invisible | Pretending 100% visibility |
OS policy, power, revoked permission — infer via cohort gaps.
Architecture (TB funnel, not an LR row)
Flow
- 1
1 queued
- next2 accepted
- 2
2 accepted
- next3 delivered
- 3
3 delivered
- next4 displayed
- 4
4 displayed
- next5 opened
- 5
5 opened
- next6 engaged
- 6
6 engaged
- next7 Warehouse / stream
- 7
7 Warehouse / stream
Propagate notification_id / traceparent across queue → provider client → app open. Trace concepts: OpenTelemetry traces and the SLI cluster — do not recap sampling here.
Diagrams - step by step
Three small diagrams for notification analytics. Step numbers in the labels give the animation order. The lesson map under Diagram 1 plays those steps.
Diagram 1 - Happy path: the funnel joined on notification_id
Flow
- 1
Step 1 queued - intent persisted with nid
- nextStep 2 accepted - provider HTTP success, store provider message id
- 2
Step 2 accepted - provider HTTP success, store provider message id
- nextStep 3 delivered - device ack when available
- count accepted as seenFailure path - inflated reach, silent drops hidden
- 3
Step 3 delivered - device ack when available
- nextStep 4 displayed - SDK or OS hook
- 4
Step 4 displayed - SDK or OS hook
- nextStep 5 opened - the tap carries nid
- 5
Step 5 opened - the tap carries nid
- nextStep 6 engaged - in-app action within the attribution window
- 6
Step 6 engaged - in-app action within the attribution window
- nextStep 7 Join on nid in the warehouse using server time
- 7
Step 7 Join on nid in the warehouse using server time
- 8
Failure path - inflated reach, silent drops hidden
Each stage is a separate event keyed by the same nid. Accepted means the provider took it, not that a person saw it. Delivered and displayed are often incomplete, so do not pretend full visibility.
Lesson map
End-to-End Notification Analytics — Send, Delivery, Receipt & Engagement
Diagram 1 walks 7 steps from Step 1 queued - intent persisted with nid through Step 7 Join on nid in the warehouse using server time.
Architecture. Step 1 queued - intent persisted with nid Ready. Step 2 accepted - provider HTTP success, store provider message id Ready. Step 3 delivered - device ack when available Ready. Step 4 displayed - SDK or OS hook Ready. Step 5 opened - the tap carries nid Ready. Step 6 engaged - in-app action within the attribution window Ready. Step 7 Join on nid in the warehouse using server time Ready. Failure path - inflated reach, silent drops hidden Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 queued - intent persisted with nid Ready"] B["Step 2 accepted - provider HTTP success, store provider message id Ready"] C["Step 3 delivered - device ack when available Ready"] D["Step 4 displayed - SDK or OS hook Ready"] E["Step 5 opened - the tap carries nid Ready"] F["Step 6 engaged - in-app action within the attribution window Ready"] W["Step 7 Join on nid in the warehouse using server time Ready"] X["Failure path - inflated reach, silent drops hidden Ready"] A -->|continues| B B -->|continues| C C -->|continues| D D -->|continues| E E -->|continues| F F -->|continues| W B -->|count accepted as seen| X
Diagram 2 - Failure path: client clock skew drops opens
Sequence
- 1
Send service → Warehouse
Step 1 sent at 10:00 server time
- 2
Phone → Phone
Step 2 phone clock is 2 h behind
- 3
Phone → Warehouse
Step 3 open event stamped 08:05 client time
- 4
Warehouse → Warehouse
Step 4 join drops the open because it looks earlier than the send
- 5
Send service
Step 5 open rate is under-reported
- 6
Send service
Fix - server receive time for joins, keep client_event_ts for debugging
Client clocks drift and can be set by hand. Use the server receive time to order and join funnel events, and keep the client timestamp only as a diagnostic field.
Diagram 3 - Decision: which signal answers which question
Decisions
- ?
Step 1 Question to answer?
- did the provider take it?accepted rate
- did a person see it?Step 2 First-party SDK instrumented?
- did it drive value?engaged within the attribution window
- 2
accepted rate
- Wrong pick as a reach metricaccept is not display, display is not open
- ?
Step 2 First-party SDK instrumented?
- yesdisplayed and opened, joined on nid
- noUnknown - infer from cohort gaps
- 4
displayed and opened, joined on nid
- 5
Unknown - infer from cohort gaps
- 6
engaged within the attribution window
- 7
accept is not display, display is not open
Pick the metric that matches the question. Provider accept is a strong delivery-pipeline signal but says nothing about human attention; opens and engagement need first-party instrumentation.
Durable funnel events ride a bus whose delivery semantics live in Kafka.
Clock skew and attribution
- Use server receive time for funnel joins; carry
client_event_tsseparately. - Skew-tolerant windows (e.g. open within 1h of send, configurable).
- Multi-device: attribute open to
device_idbut conversion touser_idcarefully. - Collapse/replace: later send supersedes; do not double-count opens across collapsed nids without rules.
- Tracing: same
nidon outbound workers and the open beacon.
Privacy
- Payloads: opaque
nid, type codes — never email/phone/message body secrets. - Analytics events: hash user keys per policy; respect opt-out / deletion.
- Rich media URLs: short-lived; do not embed account tokens in query strings logged by CDNs.
Sandbox: funnel reducer (Python)
Rates are unique nids per stage over queued. Educational — not a warehouse.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Same idea (TypeScript): open beacon
Playgrounds cannot fetch. An in-memory store stands in for POST /v1/push/events. Server stamps tsServer.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Failure modes
- Analytics lag — warehouse delay misleads "delivery drop" pages; show pipeline lag SLO.
- Missing nid on legacy payloads — opens unattributed; enforce schema at compose.
- Counting provider accept as delivered — overstates success to execs.
- PII in data payload — compliance incident; redact in a compose linter.
- Double open events — debounce on nid+device; idempotent event upsert.
Pitfalls
Match 77 sends nid-1 (0-0) then nid-2 (1-0) with the same collapse key. The user opens the 1-0 banner. What is queued, accepted, opened for nid-1 vs nid-2? Write the rule before you ship the chart.
Interview Q&A
Is FCM 'delivered' equal to 'the user saw it'?
Answer
No. Accept ≠ display ≠ open. FCM tools are aggregates; the product still needs a first-party funnel.
How do you join provider ids?
Answer
Store provider_message_id next to nid at accept time. Retries may mint a new provider id; nid stays stable. Depth: reliability.
How do you handle clock skew?
Answer
Server timestamp is authoritative for joins. Keep client ts for diagnostics. Use a configurable attribution window.
Privacy baseline?
Answer
No PII in the push payload. Minimize in analytics events. Hash keys. Honor opt-out and deletion. Short-lived media URLs.
How do you trace across services?
Answer
Same nid plus distributed trace context on outbound workers and the open beacon. Do not recap W3C sampling here.
Where does Kafka fit?
Answer
Durable funnel stream. Delivery semantics (acks, retries, EOS) stay on Kafka delivery.
Which SLIs?
Answer
accept_rate, open_rate, p95 send→open. How to design the ratio: SLIs.
How does collapse affect metrics?
Answer
Define whether superseded nids count as abandoned. Otherwise your open rate lies after every score update.
Displayed vs delivered?
Answer
Delivered is a device/OS ack when you have one. Displayed is shade presentation via SDK/OS hooks. Both are best-effort; do not pretend they are complete.
Double opens?
Answer
Debounce on nid+device. Idempotent upsert of the funnel event. The same tap should not move rates twice.