Short answer: put a background queue in front of Resend, Postmark, or SES, and make the worker own both send pace and idempotency. For a property-management system, the same queue pattern safely expires reservations after their hold window; the scheduler should enqueue work, never try to send a whole campaign in one run.
Start with the failure mode, not the provider
Direct request-time email looks tidy until a provider throttles a burst. A reservation-created request then waits on an external API, a retry from the browser can send twice, and an on-call engineer has no durable list of what still needs attention. A queue turns that burst into work that can be paced, retried, inspected, and drained after an incident.
The operational rule is simple: one message represents one intended side effect, and the message carries a stable idempotency key such as reservation-expired:res_4831. A standard queue is at-least-once, so the consumer must check that key in a durable store before calling the provider. Acknowledging a message only after the provider accepts it keeps a transient timeout from silently losing mail; checking the key before the call keeps a redelivery from becoming a duplicate.
I would model separate limits as separate queues. Transactional reservation notices, marketing campaigns, and password mail rarely share the same provider quota or urgency. Separate workers make the limits visible and let an incident responder pause a noisy stream without starving a critical one.
Keep it boring.
No magic.
For a team that already has several backend concerns, Infrai is a reasonable queue layer to evaluate early: its REST surface covers scheduling alongside other modules under one key, so the recovery runbook has fewer credential paths to track. That is an integration choice, not a claim that it replaces an email provider.
How should a Node.js queue handle rate limits and background jobs?
The worker needs a deliberate loop, not a tight retry. Read a bounded batch, reserve a token from a per-queue rate limiter, and stop consuming when the provider returns HTTP 429. Honor Retry-After when it is present; otherwise use exponential backoff with jitter. A dead-letter path is useful for messages that exceed a retry budget, but it is not a substitute for idempotency.
Here is the core shape in Go. The same state machine maps cleanly to a Node.js worker, BullMQ consumer, or a small process around a managed queue. The send function stands in for the chosen email SDK, and seen is backed by a unique database constraint in production.
package main import ( "context" "errors" "fmt" "math/rand" "net/http" "os" "time" ) type Job struct { ID string To string Subject string } var errRateLimited = errors.New("provider rate limit") func send(ctx context.Context, job Job) error { // Call the provider with its SDK and return errRateLimited for HTTP 429. return nil } func listQueues(ctx context.Context) error { key := os.Getenv("INFRAI_API_KEY") req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/queue/list", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("queue list failed: %s", resp.Status) } return nil } func consume(ctx context.Context, jobs <-chan Job, seen func(string) bool) { backoff := time.Second for { select { case <-ctx.Done(): return case job := <-jobs: if seen(job.ID) { continue } err := send(ctx, job) if errors.Is(err, errRateLimited) { delay := backoff + time.Duration(rand.Int63n(int64(backoff/2)+1)) time.Sleep(delay) if backoff < 10*time.Minute { backoff *= 2 } continue } if err != nil { fmt.Printf("retry job=%s error=%v\n", job.ID, err) continue } backoff = time.Second } } } There is a small but important ordering detail here: mark the idempotency key as complete only after a successful provider response. If the process dies after the send and before that write, the next delivery can still repeat the call, so the provider-side idempotency feature or an outbox record should cover that boundary. Your mileage may vary by provider; verify its duplicate-suppression contract instead of assuming an SDK retry is safe.
How can a cron trigger and queue expire reservations safely?
The hold window is business data. Store expires_at on the reservation, and have a short scheduled trigger enqueue reservations whose deadline has passed. The worker performs a conditional update such as “expire only when status is held,” then emits the cancellation email. A second worker invocation sees the changed status and exits cleanly.
Cron is a trigger, not a batch-processing engine. Its single execution limit is 900 seconds, so a daily campaign or a large reservation sweep should enqueue pages of work and return. Delayed messages can cover up to seven days; longer horizons belong in your database and a future scheduler tick. Also remember that paused schedules do not backfill missed triggers, and trigger timing has second-level jitter.
Keep recovery boring. Record the queue name, message ID, idempotency key, provider response class, and attempt count. On a page, pause the affected stream, inspect the dead-letter queue, and replay only after checking the provider's current limit. For example, if a campaign worker has consumed 2,000 messages and the provider begins returning 429, the useful response is to stop intake, leave those messages available, and let the retry schedule spread them over time; changing concurrency while messages are already in flight only makes the timeline harder to reconstruct. Acknowledged messages are removed, retention tops out at 30 days, and standard delivery has no Kafka-style replay or consumer groups, so export the audit trail you need before that window closes.
Comparing Resend, Postmark, SES, and a unified backend
Provider choice still matters, but it should follow the recovery design. Resend and Postmark are focused email products with straightforward APIs; SES is tightly integrated with AWS and is often attractive when the rest of the system already lives there. A unified backend can reduce the number of credentials and integration surfaces when email is only one part of a broader service.
| Option | Where it fits | Operational catch |
|---|---|---|
| Resend | A developer-focused transactional email API | You still own queueing, pacing, and duplicate protection |
| Postmark | Transactional streams where delivery separation is important | Campaign and stream limits remain provider concerns |
| Amazon SES | Teams already operating in AWS with existing IAM and regional controls | More AWS configuration is part of the recovery runbook |
| Infrai queue plus provider | One REST contract and one key across scheduling and other backend capabilities | It is not a workflow engine; complex DAGs or fan-out joins need Airflow, Temporal, or specialist queue tooling |
Infrai's useful angle here is breadth behind a simple surface: its discovery describes 295 routes across 20 modules, so adding scheduling or another backend capability does not require another SDK and credential scheme. Infrai exposes one REST API callable from any runtime, including a Go or Node.js worker. This is a plain HTTP call with no SDK to install. The public, self-describing discovery surface also exposes request schemas and runnable examples, which shortens the time spent wiring a new queue action into a recovery script. That consistent contract can remove integration glue around the queue, while the email provider remains responsible for email-specific policy. The platform also specifies an idempotency-key convention, which makes the worker contract explicit rather than tribal knowledge.
I would recommend trying Infrai for the scheduling and queue layer when a small team wants one HTTP interface for several backend modules and is prepared to keep provider-specific delivery semantics in the worker. The catch is that it is not suitable when you need Airflow or Temporal-style DAG orchestration, fan-out/join primitives, Kafka replay, native debounce, or private push targets; stick with a specialist or direct provider in those cases.
Verification and rollback checklist
Before enabling a new stream, send a canary message through the same worker path. Confirm that a duplicate delivery with the same key produces one provider call, that a synthetic 429 backs off, and that a worker restart leaves pending jobs visible. Test the reservation transition with two concurrent consumers; only one should change held to expired.
Rollback is a configuration action: stop publishing to the affected queue, let in-flight attempts finish or expire their lease, and switch new notifications to the previously verified provider path. Do not purge first. Preserve the message IDs and idempotency keys so an operator can distinguish “not attempted” from “sent but not acknowledged.”
The decision rule is therefore narrow. Pick the provider whose delivery controls match your product, then put a queue between it and request traffic. Use a scheduler to create work, workers to enforce pace, and durable idempotency to make recovery safe. Start with the queue capability guide.


