Notification System Trade-offs & Decisions
The key architectural decisions in a notification system and the trade-offs that make each one genuinely non-obvious — from the foundational sync-vs-async and retry questions through priority fairness, queue topology, ingestion durability, rate limiting, and provider failover.
Synchronous vs Asynchronous Delivery
Should the notification API wait for the provider to confirm delivery before returning?
Synchronous
Immediate delivery confirmation. Simple to reason about. But: producer latency becomes coupled to provider latency, provider slowness cascades upstream, there is no burst absorption, and a provider failure becomes an API failure.
Asynchronous (queue-based)
Producer returns in milliseconds. Burst absorption. Provider isolation. Independent scaling. But: delivery is eventually consistent, and the producer cannot know the delivery outcome synchronously.
Retry Policy
How should the system retry failed notifications?
Immediate retry
Simple. But during a provider failure, retries amplify load exactly when the provider is least able to absorb it — a retry storm.
Exponential backoff with jitter
Wait time roughly doubles per attempt; random jitter prevents synchronized retries across workers. A strong default for transient errors, though it must be paired with retryable/permanent error classification (see below) and an overall notification deadline so backoff doesn't quietly delay a time-sensitive message past the point of usefulness.
Max retry limit + DLQ
After N attempts, move to the DLQ. Prevents unbounded retry accumulation and makes failures observable rather than silently repeating forever.
Single vs Multiple Providers
Should each channel have one provider or multiple?
Single provider
Simple integration, one set of API semantics, one rate limit to reason about. But a provider outage becomes a full channel outage.
Multiple providers
Primary/fallback routing gives higher resilience. But different APIs, different delivery semantics, and more complex monitoring/billing — and, critically, failover after an ambiguous primary-provider outcome can duplicate delivery (see Provider Failover vs Retry Primary below).
At-Least-Once vs Exactly-Once
Can the system guarantee each notification is delivered exactly once to the end user?
At-least-once + idempotent processing
The queue delivers at-least-once; workers make internal state transitions idempotent, use idempotency keys at the producer/API and provider boundaries where available, and reconcile using provider callbacks/status APIs. This is a practical, achievable target for internal processing.
Exactly-once end-user delivery
Cannot be guaranteed once an external provider is in the loop. The ambiguous-outcome race (provider accepts, worker crashes before recording success, message is redelivered) means a retry can produce a genuine duplicate delivery, no matter how good the internal idempotency machinery is.
Strict Priority vs Weighted/Fair Priority
Should workers always drain the highest-priority queue to zero before touching lower tiers?
Strict priority
Minimizes latency for critical traffic in the common case — the highest tier is always served first when anything is waiting. But if the top tier sustains enough volume to never fully empty, lower tiers can be starved indefinitely, not just delayed.
Weighted/fair scheduling (with aging or reserved capacity)
Guarantees bounded progress for every tier — no class waits forever — at the cost of a small amount of scheduler and capacity-planning complexity, and slightly less aggressive best-case latency for the top tier under sustained high-priority load.
Direct Publish vs Transactional Outbox
How should the API get a validated notification durably into both the database and the queue?
Direct dual write
The API writes to the database, then separately publishes to the broker. Simple to implement, but has two distinct partial-failure windows: the DB write succeeds and the publish fails (the notification is recorded but never processed), or the publish succeeds and the DB write fails (a worker processes something with no durable record).
Transactional outbox
The transactional outbox pattern: the notification and an outbox row are written in one database transaction; a separate publisher reads unpublished outbox rows and sends them to the broker, retrying until acknowledged. This closes the dual-write gap — but adds a publisher process, outbox table storage, and a cleanup/retention strategy for published rows as new operational surface.
Centralized vs Distributed Rate Limiting
When many workers share one provider account's rate limit, where does the limiting logic actually live?
Centralized shared limiter
A single shared counter (e.g. a token bucket in a fast shared store) gives an accurate, global view of remaining provider capacity across all workers. Cost: a network round-trip (and potential contention) on every send attempt.
Per-worker local limiting
Each worker enforces its own slice of the limit with no coordination. Simple and fast, but inherently inaccurate — the sum of independently-enforced local limits can still exceed the provider's actual ceiling, or leave capacity unused if traffic is unevenly distributed across workers.
Distributed / leased quota
A middle ground: workers periodically lease a slice of the total budget from a central coordinator and spend it locally between leases. Reduces round-trip frequency versus a fully centralized limiter, while staying more accurate than pure per-worker limiting — at the cost of some quota sitting idle in an underused lease at any given moment.
Provider Failover vs Retry Primary
When a primary provider call fails or times out, should the system fail over to a secondary provider immediately, or retry the primary first?
Immediate failover
Improves apparent availability — the user gets a delivery attempt through a different path right away. But if the primary's outcome was ambiguous (a timeout, not a known rejection), the primary may have already accepted the send, and failing over risks a duplicate delivery to the end user.
Retry primary first
Safer with respect to duplicate risk when the primary is only transiently degraded, but delays delivery further if the primary is genuinely down for an extended period, and it doesn't help at all if the primary's outage is sustained.
Render Before Queue vs Render at Delivery
Should the notification's final content be rendered from its template when it's enqueued, or right before it's actually sent to the provider?
Render at enqueue time (snapshot)
The rendered content (or a reference to the exact template version used) is fixed at creation time. Guarantees a delayed or retried notification sends the content the recipient expects based on when it was triggered — a template edit made afterward cannot silently change in-flight messages. Costs slightly more storage per notification and means a legitimate late content fix can't retroactively apply to already-enqueued messages.
Render at delivery time
Always uses the current template/variables at the moment of send. Lets a corrected template apply to everything not yet sent — useful for fixing a broken template mid-flight. Risk: a notification that sits in a retry queue for an extended period can be delivered with content that has since changed, which may not match the context the notification was originally triggered for.
Decision summary
| Decision | Default | Switch when |
|---|---|---|
| Sync vs async delivery | Asynchronous, queue-based | A narrow synchronous path for a low-volume, latency-critical flow |
| Retry policy | Exponential backoff with jitter, max attempts, then DLQ | Permanent errors skip retry entirely; deadlines expire stale notifications |
| Single vs multiple providers | One provider per push platform (FCM/APNs) | Multiple providers pay off most for SMS and email |
| Delivery guarantee | At-least-once with idempotent processing | Never exactly-once end-user delivery once an external provider is involved |
| Priority scheduling | Weighted/fair with aging or reserved capacity | Strict priority only where starvation of lower tiers is acceptable |
| Queue topology | Channel-specific queues | A shared queue where simplicity outweighs weaker isolation |
| Ingestion write path | Transactional outbox | Direct publish only if the two partial-failure windows are tolerable |
| Rate limiting | Distributed / leased quota | Centralized limiter for accuracy; per-worker local limits are inherently inaccurate |
| Failover vs retry | Depends on the failure signal | Fail over on a known rejection or urgent class; retry primary after an ambiguous timeout |
| Template rendering | Reference a specific template version at creation | Render at delivery to fix a broken template mid-flight; snapshot when content must match trigger time |