DEV Community

grahamprice3746
grahamprice3746

Posted on

Node.js SaaS Message Queues: At-Least-Once Delivery, Delayed Retry, and Idempotency

Short answer: Choose a simple message queue that makes acknowledgement timing explicit, then implement delayed retry, idempotency, and the dead-letter boundary as application policy. For a Node.js SaaS retrying failed jobs, the decisive question is not which broker promises the strongest slogan; it is whether the system can prove that an at-least-once redelivery produces one durable business effect, preserves an audit trail, and eventually stops retrying work that cannot succeed.

The queue transports attempts. The application owns correctness.

This architecture decision record therefore selects explicit consumer acknowledgements, a durable idempotency record in the system of record, bounded delayed retries that respect Retry-After after an HTTP 429 response, and a dead-letter queue with an owned replay procedure. It rejects in-process memory as the authoritative retry clock. It does not select a product, because throughput, operating model, and retention obligations still determine which implementation fits.

Decision invariants and failure boundaries

The first invariant is effect-once processing over at-least-once delivery. Those phrases are deliberately different. A consumer may receive the same logical job more than once; the database must still admit only one transition for the business operation. An idempotency key should therefore identify the operation that the business considers singular, such as tenant_id + invoice_id + operation, rather than one delivery attempt. A newly generated queue message identifier is evidence about transport, not evidence that a debit, email, export, or webhook is new.

The second invariant is that a message is acknowledged only after the durable terminal decision has been recorded. RabbitMQ documents that acknowledgements tell the broker a delivery has been handled and that, when a connection or channel closes before acknowledgement, the unacknowledged delivery is automatically requeued. That is useful, but it creates a normal crash window: a worker can commit a database change and disappear before its acknowledgement reaches the broker. The resulting redelivery is not exceptional traffic. The idempotency record must absorb it.

The third invariant is bounded retry. A retryable attempt receives a future visibility time and an attempt record; a permanently invalid payload goes directly to quarantine; a valid attempt that exhausts policy moves to the dead-letter queue. These are state transitions, not logging conventions. If operators cannot answer who moved a job, from which state, at what time, under which policy version, and with which idempotency key, the retry mechanism is incomplete for a regulated workload.

No guessing.

Keep payload data sparse. A dead-letter queue extends the lifetime and access surface of whatever is placed in it, so references to protected records are generally easier to govern than copied sensitive bodies. The precise retention period is a compliance and legal decision, not a queue default, and I'm not sure a universal period can be defended without the data classification, jurisdiction, and contractual requirements in hand.

How should a Node.js SaaS message queue retry failed jobs safely?

Model one logical job as a small state machine: ready, running, retry_wait, succeeded, or dead. The transition into running needs a lease so that another worker can recover abandoned work; the transition into succeeded needs a uniqueness constraint on the business idempotency key; and every transition needs an append-only attempt record. A Node.js producer and consumer can implement this contract, while the Go example below makes the ordering explicit because the contract is independent of runtime.

Consider a concrete boundary case. A worker claims tenant_7:invoice_184:collect, calls an HTTP dependency, receives status 429, and observes a Retry-After value. MDN states that a server may include Retry-After with 429 to indicate how long the client should wait before making a new request. The worker should record the attempt and schedule its next visibility according to that instruction, then acknowledge the current delivery only after the retry state is durable. If the process stops after that database commit but before acknowledgement, the broker may redeliver immediately; the duplicate consumer reads retry_wait, sees that this business operation already has a future schedule, records the duplicate delivery for audit, and acknowledges without issuing the side effect again.

That sequence is easy to get subtly wrong. If the worker acknowledges before persisting retry_wait, a process exit can lose the job. If it holds an unacknowledged message for the entire delay, worker capacity becomes coupled to backoff duration. If it stores deduplication only in process memory, a restart erases the correctness boundary. And if every 429 is retried immediately, concurrent workers can amplify the rate limit rather than relieve it. Don't make delivery timing carry business truth that belongs in durable state.

Poison data follows a different path. A payload that cannot satisfy the versioned schema or lacks the fields required to construct its business key will not become valid through repetition; it should be classified, recorded, and quarantined without consuming the transient-retry budget. By contrast, an accepted request with a server-provided retry instruction has supplied evidence for another attempt. The distinction belongs in a typed error taxonomy so that a renamed exception or a fragment of message text cannot silently change scheduling policy.

Comparing simple retry architectures

No placement wins every axis. The relevant comparison is where the retry clock and idempotency truth live, how recovery is audited, and which component the team is prepared to operate.

Architecture Retry clock Idempotency authority Failure boundary Appropriate use Limitation
Durable queue with delayed delivery Broker or queue service Application database Delivery can repeat around acknowledgement Independent workers, backpressure, and transport decoupling Delay features and dead-letter behavior vary, so portability requires an adapter and contract tests
Database-backed job table Indexed available_at field Same database transaction as business state Poller leases can expire and be reclaimed Moderate job volume where transactional coupling and audit queries dominate Polling, index maintenance, and lease fairness become application responsibilities
Transactional outbox plus queue Outbox dispatcher and queue policy Business database plus unique effect key Publishing can repeat after dispatcher uncertainty Jobs created in the same transaction as domain changes Two recovery loops must be observed: outbox publication and consumer execution
In-process scheduler Process memory or local persistence Usually application-specific Restart or replacement can interrupt local scheduling Disposable work whose loss and duplication are acceptable Not suitable as the sole authority for durable SaaS jobs

For a small SaaS with a relational database already inside its correctness boundary, a database-backed job table is often the simplest baseline to evaluate, because enqueueing can share a transaction with the domain change and reconciliation can use ordinary queries. The catch is operational: polling load, lease contention, cleanup, and fair scheduling now belong to the application team. A durable queue becomes more compelling when independent scaling, backpressure, and fan-out matter enough to justify another stateful component. A transactional outbox is warranted when publishing must follow a domain commit without a dual-write gap, although it doesn't remove consumer idempotency.

“Simple” should mean few correctness authorities, not few processes.

The comparison also needs deployment evidence. Test by terminating a consumer after the effect commits but before acknowledgement, delivering the same payload concurrently, advancing the retry clock, exhausting the attempt policy, and replaying a dead letter twice. Observe queue age, ready depth, in-flight count, retry count by reason, dead-letter age, lease expirations, duplicate suppressions, and time from first attempt to terminal state. Cost evaluation should include retained payload bytes, request operations, database polling, operator time, and the compliance burden of duplicated sensitive data; a low per-operation fee does not compensate for an opaque replay path.

Critical path: claim, classify, record, then acknowledge

The transport adapter should expose acknowledgement separately from handling so that the ordering can be reviewed. The following Go sketch leaves storage and transport behind interfaces, while preserving the important states. Store.BeginAttempt atomically returns the existing terminal or scheduled disposition for a duplicate key, or creates a running attempt with a lease. Store.ScheduleRetry, Store.MarkSucceeded, and Store.MarkDead each append an immutable audit event in the same transaction as the state change.

Ordering wins.

package jobs

import (
    "context"
    "errors"
    "time"
)

type Disposition int

const (
    Run Disposition = iota
    AlreadySucceeded
    AlreadyScheduled
)

type Job struct {
    IdempotencyKey string
    Payload        []byte
}

type RetryableError struct {
    RetryAfter time.Duration
    Cause      error
}

func (e *RetryableError) Error() string { return e.Cause.Error() }
func (e *RetryableError) Unwrap() error { return e.Cause }

type PermanentError struct{ Cause error }

func (e *PermanentError) Error() string { return e.Cause.Error() }
func (e *PermanentError) Unwrap() error { return e.Cause }

type Store interface {
    BeginAttempt(context.Context, Job) (Disposition, error)
    ScheduleRetry(context.Context, Job, time.Time, error) error
    MarkSucceeded(context.Context, Job) error
    MarkDead(context.Context, Job, error) error
}

type Delivery interface {
    Job() Job
    Ack(context.Context) error
    RejectToDeadLetter(context.Context) error
}

type Worker struct {
    store Store
    now   func() time.Time
    run   func(context.Context, Job) error
}

func (w *Worker) Handle(ctx context.Context, d Delivery) error {
    job := d.Job()
    disposition, err := w.store.BeginAttempt(ctx, job)
    if err != nil {
        return err
    }
    if disposition == AlreadySucceeded || disposition == AlreadyScheduled {
        return d.Ack(ctx)
    }

    err = w.run(ctx, job)
    if err == nil {
        if err := w.store.MarkSucceeded(ctx, job); err != nil {
            return err
        }
        return d.Ack(ctx)
    }

    var permanent *PermanentError
    if errors.As(err, &permanent) {
        if err := w.store.MarkDead(ctx, job, permanent); err != nil {
            return err
        }
        return d.RejectToDeadLetter(ctx)
    }

    var retryable *RetryableError
    if !errors.As(err, &retryable) {
        return err
    }
    next := w.now().Add(retryable.RetryAfter)
    if err := w.store.ScheduleRetry(ctx, job, next, retryable); err != nil {
        return err
    }
    return d.Ack(ctx)
}
Enter fullscreen mode Exit fullscreen mode

The code intentionally refuses to infer retryability from every error. Transport libraries tend to expose broad error types, yet the scheduler needs a narrow decision made near the integration boundary: a 429 with an understood retry instruction can become RetryableError; a schema violation can become PermanentError; an unclassified error remains unacknowledged until policy or operator action resolves its meaning. The attempt limit belongs inside the atomic scheduling transition, where concurrent deliveries cannot both grant an extra attempt.

There is still an external-side-effect gap. No local transaction can atomically commit with an unrelated HTTP service, so the outbound request should carry the same stable idempotency key when that service supports one; otherwise reconciliation must compare local intent with remote outcome before replay. Exactly-once thinking is valuable here as a design discipline, but a queue acknowledgement alone cannot manufacture a distributed transaction.

Rejected option and the case for choosing it

The rejected design is an in-process timer that catches an error, sleeps, and calls the handler again. It is attractive because it has almost no setup, yet it binds delay to a particular process, consumes concurrency while waiting, obscures attempts inside one long execution, and cannot provide durable recovery after replacement without adding another persistence mechanism. It is not suitable for failed jobs whose loss or duplicate side effect would require reconciliation.

There is a valid use case. Stick with an in-process scheduler for best-effort cache refreshes, replaceable previews, or other work whose explicit contract permits loss and duplication, especially when the operation finishes within one process lifetime and no audit record is required. For durable tenant-visible work, choose between a database-backed table, a queue, or an outbox by testing the failure boundaries above; the correct choice is the one whose duplicate delivery, delayed visibility, quarantine, and replay behavior the team can demonstrate under controlled process termination.

References

Top comments (0)