Short answer: for rate-limited email sending in a marketplace, put a durable queue between the shipment event and the Node.js backend, then enforce the downstream limit in the worker. Keep urgent tracking notifications separate from bulk updates, measure queue age as an SLO, and choose a managed service only when its operational reduction is worth the recurring cost and lock-in.
The decision is about latency versus cost, but those words need a boundary. Latency means the age of the oldest eligible notification, not just the time spent in one HTTP request. Cost includes idle capacity, retries, storage, on-call work, and the engineering time required to recover a stuck queue. A design that looks cheap at one message per second can be expensive when a carrier status import creates a burst across thousands of orders.
This is the failure pattern I use in capacity reviews: an application accepts the shipment change quickly, a worker paces outbound delivery, and a durable record says which subscriber should receive which version of the update. The queue absorbs the burst. It does not decide whether the business event is still useful.
How should a marketplace fan out shipment updates through rate-limited background jobs?
Start with an event, an eligibility decision, and a delivery attempt as separate facts. When an order changes from in_transit to delivered, the transaction records the shipment version. A separate dispatcher expands that event into subscriber jobs. Each job carries a stable key such as shipment:order-731:delivered:subscriber-1842, a priority, and a not-before time. The worker can then retry one subscriber without replaying the entire fan-out.
The rate limiter belongs at the boundary that owns the limit. If the delivery provider permits 40 calls per second and the marketplace generates 120 eligible calls per second for a short period, the backlog grows by 80 calls per second during that period. Adding more workers without changing the limiter only creates a faster group of workers waiting for the same scarce capacity. I leave headroom for password resets, fraud notices, and carrier exceptions rather than planning to consume the advertised ceiling continuously. Consider a carrier import that marks 3,600 orders delivered in one minute, with four subscribers eligible for each shipment update: that is 14,400 delivery jobs arriving in a minute before retries, and the useful question is how many minutes of queue age the chosen limit creates, which traffic class gets priority, and what the operator sees while the system drains. The arithmetic is deliberately plain because a capacity plan should survive a tired on-call engineer reading it at 03:00; a dashboard that shows only worker count conceals the actual user-facing delay.
Keep classes separate. A shipment update that can arrive two minutes late should not share a concurrency budget with an account-security notification. Separate queues, or at least separate lanes with explicit weights, make the policy visible in dashboards and in the SLO review. The useful alerts are oldest eligible job age, backlog growth rate, delivery attempt rate, and the fraction of jobs delayed by rate limiting.
A scheduler is a trigger, not the durable campaign record. Cron can periodically find shipment events that need dispatching and enqueue bounded work, while the queue and database retain the state needed for retry and reconciliation. This also makes a missed scheduler tick recoverable: the next run can find the durable event rather than assuming that a clock tick was proof of delivery.
Measure it.
No magic.
What should a queue comparison measure besides the SaaS bill?
I compare implementations against the same workload envelope before comparing monthly prices. For this marketplace, the test data should include a quiet baseline, a carrier-import burst, many subscribers on one popular order, a full downstream limit, and a worker restart after the delivery request has left the process. The measurements should include p50 and p99 notification age, maximum backlog age, duplicate attempts, retry volume, recovery time, and operator actions.
The table below is a buy-vs-build frame, not a product ranking. “Build” can mean a small worker around an existing database; “buy” can mean a managed queue or job runner. Both still require an explicit delivery contract.
Resend, Postmark, and SES illustrate the provider side of that contract. They do not, by themselves, define how a Node.js backend absorbs a burst, separates transactional email from shipment mail, or proves that a retry will not send twice. A queue or job runner solves a different layer: background execution and job state. Comparing an email provider with a queue as if they were interchangeable produces an expensive architecture diagram.
| Approach | Latency behavior | Cost and ownership | Suitable when |
|---|---|---|---|
| Managed queue and workers | Easy burst absorption; latency depends on quotas and worker scaling | Recurring service cost, plus less queue operations work | The team values lower on-call load and accepts a service boundary |
| Database-backed jobs | Predictable when the database has spare capacity; contention can raise age | No new broker, but schema, cleanup, fairness, and worker recovery are yours | Volume is moderate and the database already has a clear SLO |
| Self-hosted broker | Fine control over batching, priority, and placement | Capacity planning, upgrades, failover, and monitoring stay with the platform team | The workload justifies operating another stateful system |
| Direct request fan-out | Lowest initial path latency at low volume | Burst protection, retries, and partial failure handling become request-path code | Subscribers are few, limits are generous, and a delayed update is unacceptable |
The cheapest option on an invoice is not automatically the cheapest option for the platform team. A database-backed queue can be a sound choice if the workload fits its write and lock budget. It becomes a bad bargain when queue polling competes with order reads, cleanup is forgotten, or every delivery class needs a different fairness policy.
For a database implementation, claim work in short transactions and let competing workers skip rows already claimed by another worker. PostgreSQL documents FOR UPDATE SKIP LOCKED as useful for queue-like tables with multiple consumers. The claim should include an expiry or lease, an attempt count, and a next-attempt timestamp; otherwise a process that dies while holding work can turn one notification into permanent backlog.
The duplicate boundary matters more than the worker count
Most queue designs provide at-least-once processing. A worker may finish the external request and die before it records success or acknowledges the job. A later attempt is therefore normal behavior, not evidence that the queue is broken. The application needs to make that second attempt safe or explicitly represent the uncertainty.
Use a unique constraint on the business key for the intended notification, then store an attempt record with states such as ready, claimed, sent, and needs_review. Do not mark a job sent before the delivery provider accepts it. Do not assume a timeout means the provider did not accept it. Those two shortcuts produce opposite failures: lost updates and duplicate updates.
Here is the pacing boundary in a compact Go example. The DeliveryClient is deliberately an interface; the production adapter should implement the provider's documented idempotency and retry contract, while the queue remains independent of that choice.
package main
import (
"context"
"fmt"
"time"
)
type Job struct {
Key string
OrderID string
SubscriberID string
NotBefore time.Time
}
type DeliveryClient interface {
Send(ctx context.Context, job Job) error
}
func run(ctx context.Context, jobs <-chan Job, client DeliveryClient, perSecond int) error {
if perSecond < 1 {
return fmt.Errorf("per-second limit must be positive")
}
interval := time.Second / time.Duration(perSecond)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
if wait := time.Until(job.NotBefore); wait > 0 {
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
if err := client.Send(ctx, job); err != nil {
return fmt.Errorf("send %s: %w", job.Key, err)
}
}
}
}
This code demonstrates pacing, not exactly-once delivery. A real dispatcher must atomically claim the durable job, release the database transaction before the network call, and record the result with a policy for an unknown response. For a rate-limit response, honor the documented retry delay and return the job to a state that preserves its business key. A tight retry loop is a capacity incident waiting to happen.
Which failure modes change the latency-versus-cost choice?
The common mistake is sizing workers for average traffic. Average traffic hides the carrier import that arrives in one batch, the order with unusually many subscribers, and the provider limit that changes by traffic class. Plan from peak arrival rate, permitted service rate, burst duration, and the maximum acceptable queue age. If the arrival rate stays above the service rate, no scheduler setting fixes the arithmetic; you need admission control, more permitted capacity, or a lower fan-out obligation.
Fan-out also creates a fairness problem. One popular order can occupy every worker while thousands of other buyers wait. Partitioning by order, tenant, or priority can help, but each partition increases scheduling state and observability work. I prefer the smallest policy that protects the user-visible SLO, then test it with a synthetic order containing a large subscriber set.
There is a second boundary around payloads. Store the canonical shipment event and subscriber reference, then keep the queue message small enough that retries do not duplicate a large mutable document. The worker reads the version it is supposed to send and refuses to silently replace it with a newer state unless the business rule explicitly permits coalescing. “Latest status only” can reduce cost, but it is not equivalent to “deliver every status.”
Your mileage may vary on that trade-off. If customers need an audit trail of each carrier transition, coalescing is unsuitable. If only the current delivery state matters, collapsing obsolete pending jobs may be the right cost control, provided the collapse is observable and does not erase a security-critical notice.
When is a rate-limited queue the wrong tool?
The catch is that a queue adds durable state and operational responsibility. It is not suitable when the team cannot own backlog alerts, retry policy, dead-letter handling, and reconciliation. In that case, keep the path synchronous or choose a managed execution boundary with an SLO you can actually monitor.
Stick with direct delivery when the subscriber count is small, bursts are bounded, and the request latency budget includes the provider call. Choose a workflow engine when shipment handling needs long-running steps, compensation, or human approval. Choose a log-oriented system when replay and several independent consumer groups are primary requirements. A basic job queue is the wrong abstraction for all three cases.
I would also reject a design that treats a cron expression as its audit log. The scheduler should create or discover work; a durable event table should answer what happened, what remains eligible, and why a subscriber was skipped. That distinction is what lets an operator repair a partial fan-out without guessing which recipients already received the update.
The decision rule is therefore narrow: use a paced durable queue when shipment fan-out is bursty, delivery can be asynchronous, and the team can operate an age-based SLO. Use direct calls for genuinely small, latency-critical fan-out. Use a workflow or log system when the required semantics exceed independent retryable jobs. The provider choice comes after those boundaries, because changing a delivery adapter is usually less disruptive than discovering that the system never had a durable definition of “sent.”
Top comments (0)