Backend architecture for multi-channel notification delivery.
Runtime model: Container-first (persistent Bun process), with Vercel serverless as compatible fallback.
Routing Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ NotificationService.send() │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Input: { userId, type, title, body, entityRef?, actionUrl?, groupKey? } │
│ │
│ 1. Create in-app notification record (always) │
│ 2. Load user settings + connected channels │
│ 3. Apply routing rules: │
│ - Security type → force email (cannot disable) │
│ - Check settings matrix for each channel │
│ - Skip disconnected/inactive channels │
│ 4. Create delivery records in outbox │
│ 5. routeExternal (fire-and-forget) writes outbox records: │
│ - Container: in-process worker polls and delivers │
│ - Vercel: /api/cron/[job] sweep delivers pending records │
│ │
│ Output: { notificationId, queuedChannels[] } │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Provider Interface
Each channel provider implements a common interface:
| Method | Purpose |
|---|---|
send(delivery) |
Send notification, return result |
validateConnection(userId) |
Check if channel is usable |
getProviderName() |
Return provider identifier |
Delivery Result
| Field | Type | Description |
|---|---|---|
status |
enum | sent, failed, skipped |
externalId |
string? | Provider's message ID |
error |
string? | Error message if failed |
retryable |
boolean | Whether to retry on failure |
Provider Implementations
Email Provider (Resend)
| Aspect | Detail |
|---|---|
| SDK | resend npm package |
| Rate limit | 100/day free tier, 50K/mo on Pro |
| Failure modes | Bounce, spam block, invalid address |
| Retry strategy | 3 attempts with exponential backoff |
Telegram Provider (Bot API via fetch)
| Aspect | Detail |
|---|---|
| SDK | Raw fetch() to https://api.telegram.org/bot<TOKEN>/sendMessage. No framework needed for outbound-only. Add grammY only if bidirectional bot commands required. |
| Rate limit | 30 msg/sec global, ~1 msg/sec per chat |
| Failure modes | Bot blocked, chat not found, rate limited |
| Retry strategy | Retry on 429, mark inactive on 403 |
Discord Provider
| Aspect | Detail |
|---|---|
| SDK | Direct REST API calls |
| Rate limit | Dynamic, parse from response headers |
| Failure modes | Token expired, DMs disabled, rate limited |
| Retry strategy | Refresh token on 401, retry on 429 |
Critical: Discord rate limits are NOT hard-coded. Always parse:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
Web Push Provider
| Aspect | Detail |
|---|---|
| SDK | web-push npm package (VAPID) |
| Fan-out | payload.to is the v10r user id, not an endpoint — the provider loads every push_subscriptions row for that user and sends one payload per device |
| Payload | Declarative Web Push JSON: { web_push: 8030, notification: { title, body, navigate, lang } } — no PII (title = brand name, body = a generic category line) |
| Failure modes | Push service returns 404/410 for a dead endpoint |
| Retry strategy | None — see Web Push Bypasses the Outbox below |
Unlike the other three providers, Web Push never goes through the outbox — see below.
Rate Limiting Strategy
Per-Provider Limits
| Provider | Window | Max Requests | On Limit |
|---|---|---|---|
| Resend | 1 day | 100 (free) | Queue for next day |
| Telegram | 1 second | 30 global | Backoff + retry |
| Discord | Dynamic | From headers | Respect Retry-After |
Implementation Options
| Approach | Runtime | Pros | Cons |
|---|---|---|---|
| In-process tracker | Container | Zero deps, in-memory, fast | Lost on restart |
| Database-backed | Both | Persistent, no external deps | Adds DB queries |
| Inngest built-in | Vercel | Zero config, per-step limits | Requires Inngest |
| Upstash Redis | Both | Fast, distributed, survives restarts | Another service |
Recommendation: Database-backed rate tracking for container (simple, persistent). Inngest's rateLimit for Vercel deployments. Both use the same outbox table.
Outbox Pattern
Store pending deliveries in database before async processing:
Container runtime:
┌─────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ Sync │────▶│ notification_ │────▶│ In-process │
│ Handler │ │ deliveries (outbox) │ │ Worker │
└─────────────┘ └─────────────────────┘ │ (setInterval) │
│ │ └──────────────────┘
▼ ▼ │
In-app notif Delivery External providers
created persisted called async
Vercel serverless:
┌─────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ Sync │────▶│ notification_ │────▶│ /api/cron/[job] │
│ Handler │ │ deliveries (outbox) │ │ sweep │
└─────────────┘ └─────────────────────┘ └──────────────────┘
Why Outbox?
| Benefit | Explanation |
|---|---|
| Transactional safety | Delivery records created in same transaction as notification |
| Retry from source | Failed deliveries can be retried from database state |
| Observability | Full audit trail of what was sent where |
| Decoupling | Request handler doesn't wait for external APIs |
| Runtime-agnostic | Same outbox table works regardless of who processes it |
Delivery Processing (Runtime-Adaptive)
The outbox table is the contract. Who processes it depends on the runtime.
Container: In-Process Worker (Primary)
The delivery worker runs on its own setInterval in src/lib/server/jobs/delivery-scheduler.ts (separate from the 3-hourly scheduler.ts). It is gated on platform.persistent and ticks at DEFAULT_DELIVERY_INTERVAL_MS (15s). The worker logic lives in src/lib/server/jobs/notification-delivery.ts:
| Aspect | Detail |
|---|---|
| Trigger | setInterval (15s, DEFAULT_DELIVERY_INTERVAL_MS) |
| Processing | Atomic claim (FOR UPDATE SKIP LOCKED) of DELIVERY_BATCH_SIZE due rows |
| Retry | Exponential backoff via next_attempt_at; dead-lettered when the budget is spent |
| Recovery | Stale claims reclaimed after DELIVERY_CLAIM_LEASE_MS at the top of each drain |
| Advantage | Zero external dependencies, immediate pickup, full control |
The claim, the fence token, and the reaper are specified in architecture/workers.md — this table is only the routing view of them.
service.ts calls routeExternal directly (fire-and-forget) after the outbox write — there is no runtime branch and no event emit.
Vercel: Cron Sweep (Serverless)
On serverless (no persistent process), notification-delivery is a registered job (jobs/index.ts) scheduled in vercel.json at /api/cron/notification-delivery (dispatched through the generic /api/cron/[job] route). This cron is what drains pending Telegram / Discord / email deliveries on Vercel — the interval scheduler never runs there.
| Setting | Value |
|---|---|
| Endpoint | /api/cron/notification-delivery (via /api/cron/[job]) |
| Cadence | 0 8 * * * (daily — Vercel Hobby rejects sub-daily crons at deploy time; restore */5 * * * * on Pro) |
| Purpose | Process pending deliveries, retries |
Inngest is design-intent only. It is not a dependency and is never imported. Async delivery uses the in-process worker (container) plus the cron sweep (serverless).
Retry Configuration
Policy lives in $lib/server/notifications/backoff.ts (pure) with constants in $lib/server/config.ts. The delay is applied against the database clock, so a skewed app server cannot shift the queue.
| Setting | Value | Rationale |
|---|---|---|
DELIVERY_MAX_ATTEMPTS |
5 | With real backoff, 3 covers only ~2.5 min of provider downtime |
DELIVERY_RETRY_BASE_MS |
30s | Must exceed the 15s tick, or it is not a backoff |
DELIVERY_RETRY_FACTOR |
4 | Curve: 30s → 2m → 8m → 32m (~42 min total window) |
DELIVERY_RETRY_MAX_MS |
1 hour | Don't delay too long |
DELIVERY_RETRY_JITTER |
±15% | Stop a recovered provider being hit by a herd |
Terminal states are split deliberately: failed means the provider says this will never work (403, bad address, no recipient) so a Retry button is pointless; dead means a retryable fault outlived the budget and a human should look. Only dead and failed rows are eligible for the admin retry action.
Web Push Bypasses the Outbox
Push never writes a notification_deliveries row. service.ts partitions 'push' out of the router's channel list before createDeliveries runs, then calls sendPushNow() directly: it renders a generic localized category line (renderNotification('notif_push_{type}', {}, recipientLocale)) and hands it to WebPushProvider fire-and-forget. No outbox row means no retry and no dead-letter state — a push send either reaches the browser's push service on the first attempt or it doesn't. See ../pwa.md for the full payload contract and subscription lifecycle.
Error Handling
Error Classification
| Error Type | Retryable | Action |
|---|---|---|
| Network timeout | Yes | Retry with backoff |
| Rate limited (429) | Yes | Respect Retry-After |
| Bot blocked (403) | No | Mark channel inactive |
| Invalid token (401) | Maybe | Refresh token, then retry once |
| User not found | No | Mark channel inactive |
| Malformed payload | No | Log error, skip |
Channel Deactivation
When a channel fails permanently:
- Set
is_active = falseon the account row (user_telegram_accounts/user_discord_accounts) - Error detail is recorded per-delivery in
notification_deliveries.error_code/error_message— the account row stores no error string - Send notification via other channels: "Your {channel} is disconnected"
- Surface in settings UI with "Reconnect" button
Delivery Tracking
notification_deliveries Table
| Column | Purpose |
|---|---|
notification_id |
Parent notification |
channel |
email, telegram, discord (never push — see Web Push Bypasses the Outbox) |
status |
pending, processing, sent, failed, skipped, retrying, dead |
attempts |
Retry count |
provider_message_id |
External reference |
error_code |
Provider error code |
error_message |
Human-readable error |
attempted_at |
Last attempt timestamp |
sent_at |
Successful send timestamp |
Retention Policy
Not implemented. No job prunes
notification_deliveriesby status. The per-status table below is aspirational.
| Status | Retention |
|---|---|
sent |
7 days |
failed |
30 days |
skipped |
7 days |
The existing jobs/notification-cleanup.ts only touches the notifications table: it archives at NOTIFICATION_ARCHIVE_DAYS (30) and hard-deletes at NOTIFICATION_DELETE_DAYS (90).
Module Structure
src/lib/server/notifications/
├── index.ts # Re-exports public API
├── service.ts # NotificationService.send() — single entry point (NotificationType inline union)
├── router.ts # Preference resolution, channel selection
├── outbox.ts # Delivery record management
├── stream.ts # SSE: notifyUser() + connection registry (container only)
├── crypto.ts # AES-GCM token encryption (ENCRYPTION_KEY)
├── health.ts # Channel health stats
├── render-message.ts # Notification → channel message body
├── telegram.ts # Telegram link/verification helpers
└── providers/
├── index.ts # Provider registry (getProvider by channel)
├── types.ts # Provider interface
├── email.ts # Resend provider
├── telegram.ts # Raw fetch() to Bot API
├── discord.ts # Discord REST provider
└── web-push.ts # WebPushProvider (VAPID, per-device fan-out)
DB read/write helpers live under $lib/server/db/notifications/{queries,mutations,admin-queries}.ts.
The delivery worker is $lib/server/jobs/notification-delivery.ts, driven by $lib/server/jobs/delivery-scheduler.ts. There is no Inngest function — Inngest is not wired.
Runtime Detection
service.ts does not branch on runtime. After the outbox write it calls routeExternal directly (fire-and-forget). The persistent container's delivery-scheduler.ts worker (gated on platform.persistent) picks up pending records on its own interval; on serverless the /api/cron/[job] route sweeps them. No inngest.send call exists.
Related
- ./channels.md - Channel connection flows
- ./schema.md - Database tables
- ../middleware.md - Hooks integration
- ../../stack/notifications/ - Provider details
- ../pwa.md - Web push design record (payload contract, subscription lifecycle, delivery-mode verdict)