Short answer: for a small logistics SaaS sending a weekly digest, start with a standard queue and an idempotent consumer keyed by customer and digest period; pay for FIFO only when a documented business invariant requires ordered processing, because FIFO by itself does not make an external send exactly once.
The failure worth designing around is dull: the digest is accepted by the delivery service, then the worker loses its acknowledgement before the queue records completion. The retry is legitimate. The second email is not. A queue can preserve order and still present the message again, so the useful guarantee lives at the consumer boundary — where a durable business key decides whether this week's digest has already crossed the send boundary.
That is the least complex option I would put through design review.
How should a small SaaS retry failed jobs without duplicate handling failures?
Model one bounded incident before choosing a queue. At 09:00 UTC on the weekly schedule, an illustrative worker prepares a digest for an active freight customer. It submits the message, but the acknowledgement path is interrupted. No measured outage or customer event is being claimed here; this is a failure-injection case. The queue makes the job visible again, and another worker receives it. If the job identity is a random attempt ID, both attempts look new. If it is a stable key such as weekly-digest/customer-42/2026-W33, the consumer can recognize that both attempts represent one business operation.
The invariant is narrower than "messages are FIFO." It is: for each customer and digest period, create at most one durable send intent, while continuing to retry work that has not reached that state. Ordering may matter inside one customer's workflow, but global ordering is wasted capacity for independent customers.
Duplicates are normal.
There is still an uncomfortable boundary. A database transaction can atomically record the digest and its outbox intent, but it cannot generally make an unrelated delivery service participate in that same transaction. If the downstream service accepts an idempotency key, reuse the stable business key there. If it doesn't, a crash after remote acceptance and before local completion leaves an ambiguous outcome; the honest choices are possible duplicate delivery, possible missed delivery, or a reconciliation mechanism backed by downstream status. Don't label any of those "exactly once."
For the logistics digest, I would write the SLO around the user-visible result: a defined proportion of eligible customers receive one digest within the weekly delivery window, with duplicate-send rate tracked separately. The exact targets are policy inputs, not facts supplied by a queue. They should come from product tolerance and measured delivery data.
The preventative path belongs in the consumer
The core path claims a stable key in the same transaction that writes a durable outbox record. The example uses interfaces so it does not imply a particular database, queue, or mail provider. ErrAlreadyClaimed represents a uniqueness conflict on the business key; that conflict is success from the consumer's point of view because another attempt already created the same intent.
package digest
import (
"context"
"errors"
"fmt"
"time"
)
var ErrAlreadyClaimed = errors.New("digest already claimed")
type Job struct {
CustomerID string
Period string
}
type OutboxRecord struct {
Key string
CustomerID string
Period string
CreatedAt time.Time
}
type Store interface {
InsertDigestAndOutbox(context.Context, OutboxRecord) error
}
type Consumer struct {
Store Store
Now func() time.Time
}
func (c Consumer) Handle(ctx context.Context, job Job) error {
if job.CustomerID == "" || job.Period == "" {
return errors.New("customer and period are required")
}
key := fmt.Sprintf("weekly-digest/%s/%s", job.CustomerID, job.Period)
err := c.Store.InsertDigestAndOutbox(ctx, OutboxRecord{
Key: key,
CustomerID: job.CustomerID,
Period: job.Period,
CreatedAt: c.Now().UTC(),
})
if errors.Is(err, ErrAlreadyClaimed) {
return nil
}
return err
}
The storage implementation must enforce uniqueness on Key; an application-level read followed by an insert is racy. A separate dispatcher reads committed outbox records and sends them. It should carry the same key to a downstream idempotency facility when one exists, record the downstream result, and retry according to a bounded policy. Keep poison jobs visible in a dead-letter path with the original key and attempt metadata. Manual replay must preserve that key too, or the operator console quietly defeats the design.
Test the boundary, not just the happy path. Run two handlers concurrently with the same key and assert that one durable intent exists. Crash a dispatcher before send, after send, and before acknowledgement. Advance the clock across the period boundary. Then replay the dead-letter item. These tests reveal whether deduplication is tied to the business operation or merely to one delivery attempt.
FIFO, standard queues, and the cheapest defensible option
A standard queue is the default when customer digests are independent and the consumer already enforces a unique business key. It allows parallel work without pretending duplicate suppression is someone else's problem. A FIFO queue earns its place when the domain requires ordered state transitions within a defined group — for example, a later digest must observe a prior digest's committed state — and when the throughput and operational constraints of that choice fit the workload. Even then, retain consumer idempotency because retries and external side effects still exist.
The cheapest option cannot be inferred from a queue's unit price. I'm not sure which design is cheapest for a particular SaaS until the team measures active customers, payload size, retry rate, retention, peak-to-average ratio, and engineering/on-call time. Start capacity planning with eligible customers per weekly run, multiply by expected attempts, then model the dispatch peak over the actual delivery window rather than averaging it across seven days. That single correction can change the worker count and downstream rate-limit plan.
| Choice | Delivery argument | Capacity and operations | Prefer it when | Avoid it when |
|---|---|---|---|---|
| Standard queue plus idempotent consumer | At-least-once processing is made duplicate-safe at the business boundary | Parallel by customer; requires a uniqueness constraint, outbox monitoring, and replay tooling | Jobs are independent and ordering has no business value | A proven per-group order invariant would be violated |
| FIFO queue plus idempotent consumer | Adds ordering for a defined group; idempotency still protects side effects | Grouping can constrain concurrency and needs backlog analysis | Ordered transitions are part of correctness | The team wants FIFO only as a substitute for consumer design |
| Database-backed scheduler and outbox | Keeps claim and intent close to application state | More ownership: polling, leases, cleanup, failover, and on-call runbooks | Load is modest and the team can operate the datastore path | Scheduler operations would consume scarce platform capacity |
| Managed workflow or scheduling service | Moves orchestration mechanics to a service boundary | Less machinery to own, with integration and lock-in costs to assess | Retries, observability, and workflow state justify buying | A small periodic workload does not justify another control plane |
This is a buy-vs-build decision, not a queue beauty contest. Compare total cost per successful weekly digest, but keep reliability dimensions beside it: duplicate rate, late-delivery rate, oldest-job age, replay labor, and on-call pages. A low invoice paired with recurring manual reconciliation isn't cheap.
Operate the guarantee you actually chose
The dashboard should join queue and business signals. Queue depth and oldest-job age show pressure; claim-conflict count shows retries or concurrent delivery; outbox age shows stuck dispatch; eligible-customer count versus completed digests shows coverage; and duplicate reports validate the user-visible edge. Alert against the delivery-window error budget rather than on every retry, because retries are a recovery mechanism until their volume threatens the SLO.
Deployment needs the same skepticism. Add the unique constraint before enabling concurrent consumers, deploy producers that emit stable period keys, then raise worker concurrency gradually while watching database contention and downstream throttling. A rollback must not change key construction. If two application versions derive different keys for the same digest, the database will faithfully accept both.
Keep the replay runbook short: identify the business key, inspect the durable intent and dispatch state, preserve the original key, and replay only when the chosen delivery policy permits it. Do not let an operator fabricate a new attempt identifier to "get it moving." That turns an operational action into a duplicate-delivery generator.
Order is local.
The catch is that this recommendation is not suitable when every job participates in a strict shared sequence, when legal or financial workflows demand auditable orchestration beyond a weekly notification, or when the team cannot safely operate the uniqueness and outbox storage path. In those cases, use an ordered queue or managed workflow that expresses the required sequence, and budget for its constraints. Conversely, stick with a database scheduler only when its lease, cleanup, failover, and paging burden are acceptable to the team that owns it.
References
Further reading for evaluating current scheduling and workflow behavior:
Top comments (0)