09 — Push notification pipeline
09 — Push notification pipeline
Section titled “09 — Push notification pipeline”Status: Draft
Date: 2026-05-23
Owner: frontend (09 engagement-features)
Related spec: docs/superpowers/specs/2026-05-23-09-engagement-features-design.md §2–§3
Purpose
Section titled “Purpose”Single end-to-end sequence for every Web Push notification fired by nowyourlink: how the trigger reaches the push worker, how VAPID delivery is performed, what happens on success / failure, and where rate-limit + idempotency keys are stamped.
Sources of triggers
Section titled “Sources of triggers”- Queue
bid.outbid— 04 auction-engine emits on accepted bid that displaces a previous top. - Cron
closing-soon—apps/cronfires at T-120, T-30, T-5 minutes per active auction. - Saga signals (05) —
saga.charge_succeeded→won;saga.cascaded→lostfor previous top; auction-close hook →this_closefor ranks #2 and #3.
All sources POST to internal push-send Worker (apps/api/push/send) via service binding, with payload:
type PushSendRequest = { user_id: string; kind: "outbid" | "closing_soon" | "won" | "lost" | "this_close"; auction_id: string; // YYYY-MM-DD body_key: string; vars?: Record<string, string | number>; exempt_rate_limit?: boolean; // true for won/lost only dedupe_key: string; // e.g. `push:closing:2026-06-04:30m:user_42` or `out:{auction}:{user}:{bid_seq}`};Sequence
Section titled “Sequence”Idempotency / dedupe keys
Section titled “Idempotency / dedupe keys”| Trigger | dedupe_key shape | TTL |
|---|---|---|
| outbid | out:{auction_id}:{user_id}:{displacing_bid_id} | 1 d |
| closing_soon | push:closing:{auction_id}:{slot}:{user_id} where slot ∈ {2h, 30m, 5m} | 1 d |
| won | won:{auction_id}:{user_id} | 1 d |
| lost | lost:{auction_id}:{user_id} | 1 d |
| this_close | tc:{auction_id}:{user_id} | 1 d |
/push/send dedupe is global (one KV row per dedupe_key, not per
user), so every key MUST carry {user_id} — otherwise the first recipient per
slot consumes the key and the rest are silently dropped. closing_soon
originally omitted it (fixed in #436); all five kinds now include {user_id}.
SW behavior (browser side)
Section titled “SW behavior (browser side)”pushevent handler readsevent.data.json(), callsself.registration.showNotification(title, { body, icon, tag: kind+auction_id, data: { url } }).notificationclickopensdata.url(focusing existing tab if any).tagensures repeated same-kind push for same auction collapses (e.g. multiple outbids within seconds).
Failure modes & recovery
Section titled “Failure modes & recovery”- VAPID 5xx burst → events accumulate in Queue; consumer retries with backoff; Sentry alert at >2 % error rate over 5 min.
- KV propagation lag for a brand-new subscription → cron pushes may miss the first window; acceptable.
- Browser revoked permission → next attempt yields 410; cleaned up automatically.
- User signed out → push-send detects missing session-active flag and skips (signed-out users may still have valid subs until cleanup completes).
Observability
Section titled “Observability”Each push-send invocation emits:
{ user_id, kind, auction_id, endpoints_tried, endpoints_sent, endpoints_gone, dedupe_hit, rate_limited }Aggregated in Workers Logs (100 % sampling for kind ∈ {won, lost}, 10 % otherwise per master §10.2).