Short answer: for rate-limited email sending, put a durable queue between the SaaS backend and its provider, then let a worker claim and retry each renewal reminder until its deadline; don't make a cron process or an HTTP request responsible for proving that the email was sent exactly once.
That distinction matters in a SaaS backend because a renewal reminder is tied to a business date, not to the moment a worker happens to run. The reminder may be delayed by provider throttling, a deployment, or a temporary network failure, but the system still needs one explainable decision about whether it was due, attempted, accepted, retried, or abandoned. In a payment or ledger system, I would rather inspect one durable audit trail than infer truth from a green queue dashboard.
Auditability Comes Before Queue Throughput
Start with the invariant: one renewal event creates one logical reminder, identified by a stable idempotency key such as renewal-reminder:contract-1842:2026-09-01. A scheduler may enqueue that intent more than once. A worker may receive it more than once. Those are normal delivery semantics, not exceptional states. The send decision must therefore be guarded by the business record, not by a queue's transient deduplication feature.
The worker should claim due work, check the durable state, apply the sending policy, call the provider, and record the provider result in the same audit vocabulary every time. A response that says “accepted” is evidence about the provider boundary; it is not evidence that the recipient opened the message. A timeout is even less conclusive. It means the application does not know the result yet.
Keep the state machine small:
| State | Meaning | Next action |
|---|---|---|
scheduled |
The intent exists and has a due time | Claim when due |
in_flight |
A worker owns the current attempt | Reconcile or retry after lease expiry |
accepted |
The provider accepted the request | Stop sending; retain the audit record |
retryable |
The attempt did not establish acceptance | Retry with bounded backoff |
terminal |
Policy or data makes another attempt invalid | Record why and stop |
This table is an architecture decision record in miniature. It makes the failure boundary visible: a queue controls work delivery, while the application owns the meaning of a renewal reminder. Exactly-once execution is not something a background jobs product can promise across a process crash and an external email provider. Exactly-once business effect is the more defensible target, and it requires a durable idempotency record plus reconciliation.
How should a backend govern rate-limited email sending for renewal deadlines?
A cron trigger is useful for discovering due reminders and producing bounded work. It should not send every message itself. Cron is a time-based trigger with scheduling behavior; it is not a ledger, a retry policy, or an email delivery receipt. The Wikipedia description of cron is a useful reminder of that narrow role: a scheduled command starts work, and the command must own what happens after it starts.
For a small service, a PostgreSQL table can be the queue. A transaction can select due rows with FOR UPDATE SKIP LOCKED, assign a lease, and commit; another worker can then process a different row without waiting on the first worker's network call. The important part is not the particular queue technology. It is that the claim is durable and the provider call happens outside the database lock.
The critical path looks like this:
package main
import (
"context"
"fmt"
)
type Reminder struct {
ID string
IdempotencyKey string
Recipient string
}
type Result struct {
Accepted bool
Retry bool
Reason string
}
type Mailer func(context.Context, Reminder) (Result, error)
// Deliver makes the business idempotency check explicit; the database
// transaction and unique constraint belong behind these repository calls.
func Deliver(ctx context.Context, reminder Reminder, alreadyAccepted func(string) (bool, error), record func(string, Result) error, send Mailer) error {
accepted, err := alreadyAccepted(reminder.IdempotencyKey)
if err != nil {
return fmt.Errorf("read delivery state: %w", err)
}
if accepted {
return nil
}
result, err := send(ctx, reminder)
if err != nil {
return fmt.Errorf("send reminder: %w", err)
}
if err := record(reminder.IdempotencyKey, result); err != nil {
return fmt.Errorf("record delivery result: %w", err)
}
return nil
}
The example deliberately does not pretend that an in-memory boolean solves the problem. The production repository needs a unique constraint on the idempotency key and an auditable attempt record. It also needs a lease or equivalent recovery rule for a worker that dies after claiming a row. A second worker can safely inspect the record, but it cannot safely assume that a lost network response means the provider rejected the message.
Pacing belongs beside that state machine. Use a per-provider and, where necessary, per-tenant rate policy; limit concurrency; honor a provider's retry signal; and add bounded exponential backoff with jitter. A retry should carry the original idempotency key and business event ID. Creating a fresh key on every attempt turns a temporary transport problem into duplicate mail.
Three words: never spin blindly.
Compare Queue Backends by Recovery Ownership
Compare the architecture around the provider, rather than comparing product names as if they were interchangeable queue implementations. For each option, ask where due work is stored, how a claim is recovered, how retries are scheduled, how a send is deduplicated, and how an operator reconstructs the final decision.
| Option | Good fit | Trade-off |
|---|---|---|
| PostgreSQL job table | Low to moderate volume with an existing relational backend | The team owns claiming, leases, retry timing, cleanup, and worker coordination |
| Managed queue | Teams that want durable buffering and separate workers | Retention, acknowledgement, visibility, and delivery guarantees still need to be mapped to the audit model |
| Redis-backed job library | A Node.js team already operating Redis | Queue state and business state remain separate, so reconciliation is application work |
| Workflow engine | Multi-step renewal workflows with waits, joins, or human decisions | More operational and conceptual machinery than a single paced send requires |
| Event log | Multiple independent consumers and replay are first-class needs | An event log does not by itself provide a provider-aware retry or idempotency policy |
The email provider comparison has a separate axis. Evaluate authentication, provider-side rate limits, response semantics, suppression handling, delivery events, regional requirements, and the quality of its operational evidence. A cheaper API call does not remove the cost of storing attempts, investigating duplicates, handling bounces, or meeting retention policy. Your mileage may vary because those costs depend on volume, jurisdiction, and how much of the platform your team already operates.
The rejected design is “pick the cheapest SaaS endpoint and send from the cron job.” It makes the most volatile part of the system, provider pacing, share a failure boundary with deadline discovery. That design is acceptable only when the workload is genuinely tiny, losing a reminder is tolerable, and the request or cron invocation can safely absorb the provider latency. For renewal notices tied to revenue, I would choose the durable job table or a managed queue with a separate audit store.
Can Tests Prove a Renewal Deadline Was Honored?
Unit tests should cover the state transitions, but they are not enough. The dangerous interval is between provider acceptance and the local accepted write. Force a process exit at that point in an integration test, then verify that the recovery path consults the same idempotency key and records an explicit reconciliation state.
The test matrix should include a duplicate scheduler event, two workers claiming the same due row, a lease expiring during a slow provider call, a rate-limit response, a timeout with unknown provider outcome, a malformed recipient, and a reminder whose business deadline has passed. Test clocks should be injectable, because “due now” and “past the deadline” must not depend on wall-clock timing in CI.
Observe decisions, not just throughput. Useful fields include the business event ID, idempotency key, scheduled time, attempt number, claim owner, lease expiry, provider response class, next retry time, and terminal reason. Do not log message bodies or unnecessary recipient data by default. Auditability is not permission to retain everything forever; compliance obligations for personal data, financial records, and communications vary by jurisdiction and message class, so retention and deletion rules need an explicit owner.
Run a small production rehearsal with a non-delivery sink before enabling a real provider. Confirm that deployment drains or safely abandons leases, that a clock change cannot move a reminder backward, and that operators can answer “why was this reminder not sent?” from records rather than from a worker's stdout. A dashboard is helpful. It is not the record.
When Is Direct Sending the Honest Choice?
The right queue is the one whose delivery and recovery semantics your team can explain under pressure. Select PostgreSQL when relational transactions and modest volume make a table easier to audit. Select a managed queue when buffering and worker isolation matter more than minimizing components. Select a workflow engine when the renewal process has several durable waits or branches. Select an event log when replay and independent consumers are central requirements.
Stay with a direct provider call only for low-stakes, low-volume work where a delayed or duplicated reminder has an accepted business consequence. For a revenue-linked renewal deadline, the ledger-first design is slower to assemble, but it gives every retry a stable identity and every ambiguous result a place to be resolved. That is the decision criterion I would put in the ADR.
Top comments (0)