The notification sender

A patient mail carrier: it takes your message off a durable queue, knocks on the provider's door, and if no one answers, waits a little longer each time before knocking again.

The idea

People often say "notifications" to mean the whole feed-and-fan-out system. This page is about a narrower, grittier piece: the outbound delivery worker that actually hands each message to email, SMS, or push providers, and refuses to lose it.

The shape is always the same. A durable queue decouples whoever produces a notification from the worker that sends it. The worker dequeues a message, checks the recipient's preferences and quiet hours, dedups against an idempotency key, routes it to the right channel, and calls a third-party provider. Providers fail constantly in small, recoverable ways, so the worker retries with exponential backoff. When something can never succeed, the message goes to a dead-letter queue instead of spinning forever.

See it work

queue dead-letter
provider mode:
Press play to send three messages through the pipeline.

How it works

The worker is a loop. It pulls one message, refuses to send a duplicate (the idempotency key it already delivered), tries the provider, and on a transient error it sleeps for a growing delay before retrying. The backoff is min(cap, base · 2^attempt) plus a little random jitter so a thousand workers don't all retry on the same beat. When the attempts run out, or the error is permanent, the message is parked in the dead-letter queue rather than blocking the line.

BASE, CAP, MAX_ATTEMPTS = 1.0, 30.0, 5   # seconds

def worker_loop(queue, dlq, store):
    while (msg := queue.dequeue()) is not None:
        # 1. dedup: have we already delivered this exact key?
        if store.already_delivered(msg.idempotency_key):
            queue.ack(msg)                 # safe no-op, drop it
            continue

        # 2. respect the recipient before spending a provider call
        if not allowed_now(msg.recipient, msg.channel):   # opt-out / quiet hours
            queue.ack(msg)
            continue

        for attempt in range(MAX_ATTEMPTS):
            try:
                provider = route(msg.channel)              # email / sms / push
                provider.send(msg, idempotency_key=msg.idempotency_key)
                store.mark_delivered(msg.idempotency_key)  # record before ack
                queue.ack(msg)                             # remove from queue
                break
            except PermanentError:        # bad number, opted out, 400-class
                dlq.push(msg, reason="permanent")
                queue.ack(msg)
                break
            except TransientError:        # 429 / 503 / timeout
                if attempt == MAX_ATTEMPTS - 1:
                    dlq.push(msg, reason="retries_exhausted")
                    queue.ack(msg)
                    break
                delay = min(CAP, BASE * 2 ** attempt)
                delay += random.uniform(0, delay)          # full jitter
                sleep(delay)                               # then retry

Note the order: mark delivered, then ack. If the worker crashes after sending but before acking, the message reappears and is retried, but the dedup check (and the provider's own idempotency key) make that re-send a harmless no-op. Acking before the send would silently drop messages on a crash.

Cost / trade-offs

ChoiceYou getYou pay
At-least-once delivery Never silently drops a message; simple to reason about Duplicates are possible: needs idempotency keys to stay safe
Exactly-once delivery No duplicates, ever Effectively unreachable across a third-party boundary; you approximate it with at-least-once + dedup
Durable queue (fsync, replicas) Survives crashes; no lost messages Higher enqueue latency and storage cost per message
Aggressive retries Rides out brief provider blips Retry storms can amplify an outage; needs jitter and a ceiling
Giving up early (small max-attempts) Frees the worker fast; bounded blast radius Drops messages a longer retry would have delivered
Per-provider rate limits Stays inside the provider's quota; fewer 429s Caps throughput; bursts must buffer in the queue
Per-message delivery state You can answer "did it send?" and dedup correctly A row (or key) per message: real storage at scale

Watch out for

Worked example

Message A: transient 503, then delivered. An order-shipped email is enqueued (key=ord-91:shipped). The worker dequeues it, sees the recipient hasn't opted out and isn't in quiet hours, routes to the email provider, and calls send.

Attempt 1 returns 503 service unavailable: transient. With base=1s, the delay is min(30, 1·2^0)=1s, plus jitter, so it waits roughly 1 to 2s and retries. Attempt 2 returns 503 again; delay is min(30, 1·2^1)=2s plus jitter (~2 to 4s). Attempt 3 returns 202 accepted: delivered. Because the same idempotency key rode along on every attempt, even if attempt 1 had actually sent before the connection dropped, the provider would have deduped it. Delivered count +1, message acked.

Message B: dead-lettered. An SMS to +1-555-0000 is enqueued. The worker routes to the SMS provider, which returns 400 invalid number: a permanent error. There is no point retrying a number that can't exist, so the worker pushes the message to the dead-letter queue with reason="permanent" and acks it. The DLQ count goes to 1; an operator (or a repair job) can inspect it later, but the live worker moves on instead of looping forever.

Check yourself

1. Your worker delivers at-least-once and you notice some users get the same push twice. What's the fix that keeps delivery reliable?

2. A provider returns 400 invalid phone number. How should the worker treat it?