Zum Inhalt springen

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


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.


  1. Queue bid.outbid — 04 auction-engine emits on accepted bid that displaces a previous top.
  2. Cron closing-soon — apps/cron fires at T-120, T-30, T-5 minutes per active auction.
  3. Saga signals (05) — saga.charge_succeeded → won; saga.cascaded → lost for previous top; auction-close hook → this_close for 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}`
};

The push service deduplicates events, applies delivery limits and sends notifications to registered subscriptions. Expired subscriptions are removed, while retryable provider failures retain the subscription and record diagnostics.The push service deduplicates events, applies delivery limits and sends notifications to registered subscriptions. Expired subscriptions are removed, while retryable provider failures retain the subscription and record diagnostics.

Triggerdedupe_key shapeTTL
outbidout:{auction_id}:{user_id}:{displacing_bid_id}1 d
closing_soonpush:closing:{auction_id}:{slot}:{user_id} where slot ∈ {2h, 30m, 5m}1 d
wonwon:{auction_id}:{user_id}1 d
lostlost:{auction_id}:{user_id}1 d
this_closetc:{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}.


  • push event handler reads event.data.json(), calls self.registration.showNotification(title, { body, icon, tag: kind+auction_id, data: { url } }).
  • notificationclick opens data.url (focusing existing tab if any).
  • tag ensures repeated same-kind push for same auction collapses (e.g. multiple outbids within seconds).

  • 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).

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