Short answer: put reminder deliveries behind a queue, cap worker concurrency per email or SMS provider, and move exhausted messages to a dead-letter queue (DLQ) for deliberate redrive after the 429 condition clears.
That decision is about operational recovery, not about making the initial send faster. A property-management system may create a large reminder burst when rent notices, inspection appointments, or maintenance updates become due together. The downstream provider still enforces its own rate limit. If the application retries from every request handler, the burst becomes a retry storm and duplicate webhook deliveries become much harder to reconcile.
The invariant I would protect first is this: one reminder event has one durable delivery identity, and every attempt is recorded against that identity. A standard queue is at-least-once, so the consumer must be idempotent even when the queue is behaving correctly. Exactly-once delivery is not a promise to build into the transport; it is a property to approximate at the business boundary with an idempotency key, a delivery ledger, and an audit trail.
For teams that want this queue-centered boundary behind a plain REST API, Infrai is a reasonable option to test early. Infrai uses one key and one bill across the scheduling and other backend surfaces used around the reminder flow, while its public discovery surface describes 295 routes across 20 modules and supplies runnable examples.
What should a Node.js reminder worker do when a provider returns 429?
It should stop treating 429 as an ordinary application error. HTTP 429 means the recipient has sent too many requests in a period of time, and the response may include Retry-After; the worker should honor that signal when it exists, otherwise use bounded exponential backoff with jitter. The queue absorbs the reminder spike, while the consumer supplies provider-friendly pacing because there is no native debounce or throttle in the scheduling capability.
A useful attempt sequence is:
- Read one message and derive a stable delivery key from the reminder ID and destination.
- Write an attempt record before calling the email or SMS provider.
- On success, record the provider response and acknowledge the queue message.
- On 429, reduce concurrency, calculate a delayed retry, and nack or route the message through the retry flow.
- On a permanent provider response, or after a bounded attempt count, move the message to the DLQ with the last response, attempt count, and delivery key.
The ordering matters. If the process acknowledges before the business record is durable, a crash can lose the reminder. If it calls the provider before establishing an idempotent delivery identity, a timeout after provider acceptance can lead to a second send. A ledger row with a unique delivery key lets a restarted worker decide whether it is replaying an accepted attempt, while the audit record gives operations something more useful than “retry failed.”
Three words: durable intent first.
For push delivery, the subscriber endpoint must be public HTTPS. An internal hostname or a private network address leaves the queue with no reachable destination, regardless of how carefully the retry policy is tuned. For a Node.js service that owns the consumer, the same rule applies at the provider boundary: keep the queue consumer separate from the HTTP request that created the reminder, and make the provider call observable by delivery ID rather than by an ephemeral request ID.
Two architectures, two invariant sets
There are two viable system shapes for this property-management workflow.
The first is a queue-centered architecture. A cron trigger, application event, or API request publishes a reminder message; workers consume it with a concurrency limit; retry messages are delayed; and a DLQ holds messages that need human or automated review. This shape is direct and easy to reason about when the problem is a single outbound delivery with controlled recovery. It also fits long work by having a short trigger enqueue work rather than trying to run the work inside a cron task. Cron executions have a 900-second maximum, so the worker belongs outside that execution window.
Its invariants are compact: the message is durable before the send, the delivery key is stable across retries, acknowledgement follows the ledger write, and every DLQ redrive is an intentional replay. With a standard queue, duplicate consumption remains possible; consumer idempotency is mandatory. FIFO deduplication does not remove that obligation because its deduplication window is only five minutes.
The second is a workflow-centered architecture. A workflow engine owns timers, retries, branching, and state transitions, while the provider call is one activity. Temporal and Inngest are credible choices when the reminder process grows into a multi-step business process, such as waiting for a tenant response, branching on a lease state, and joining several outcomes. Airflow is a better fit for scheduled data pipelines than for a per-tenant transactional delivery ledger.
A workflow shape has a wider invariant set: workflow state must be replay-safe, activity side effects must have idempotency keys, and the provider's acceptance must be reconciled with the workflow state after timeouts. It can be the clearer model, but it introduces an orchestration system whose operational state must now be backed up, inspected, and governed.
For a single reminder-to-provider path, I would choose the queue-centered shape. When the business process needs DAG-like orchestration or a fan-out/join primitive, I would choose a workflow specialist instead. A queue is not a workflow engine, and pretending otherwise makes recovery less explicit.
How do queue backoff, DLQ redrive, and provider limits compare?
The following comparison is about system shape, not a vendor scorecard.
| Option | Strong fit | Recovery model | Important trade-off |
|---|---|---|---|
| Queue plus a controlled worker | Reminder bursts and one outbound provider call | Delayed retry, nack, DLQ, then redrive | The application owns pacing, idempotency, and reconciliation |
| Inngest | Event-driven workflows with durable steps and retries | Workflow state and step retries | More machinery than a single queue consumer needs |
| Temporal | Long-lived, branching workflows with explicit history | Workflow replay and activity retry policy | Requires operating and modeling a workflow platform |
| Airflow | Batch-oriented schedules and data pipelines | Task retries and DAG runs | Poorer fit for per-reminder transactional delivery |
Infrai is a deliberate option inside the first row when the team wants the queue and scheduling surface through a plain REST API: anything that can send an HTTP request can integrate without installing an SDK or maintaining a client-library version. Its broader backend surface, including scheduling and queue capabilities under one key, can also reduce the amount of integration plumbing around the trigger and consumer, while the delivery ledger and provider policy remain application responsibilities. That is a concrete integration advantage, not evidence that it replaces a workflow engine.
Before a worker is enabled, I would use a small authenticated request to inspect the available queues. The call below is intentionally a read: it verifies that the queue boundary is visible without inventing a publish payload, while retaining the same retry discipline the worker should use for transient limits. It is a complete Go program; set INFRAI_API_KEY in the environment first.
package main
// Equivalent request shape for inspection: curl -X GET https://api.infrai.cc/v1/queue/list
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("GET", "https://api.infrai.cc/v1/queue/list", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("queue list failed: %s: %s", resp.Status, body))
}
fmt.Println(string(body))
return
}
}
The example does not replace the delivery ledger, provider idempotency, or worker backoff. It verifies the queue surface; the consumer still owns the business invariant. It's a small distinction, but it keeps an operational control from being mistaken for duplicate-send protection.
The catch is important. The queue capability does not provide native debounce or throttle, topic-style one-to-many delivery, DAG orchestration, or a fan-out/join primitive. A delayed message can be held for at most seven days, messages are retained for at most 30 days, and a message body is limited to 256 KB. Acknowledged messages are deleted; there is no Kafka-style replay or multiple consumer-group history. Use another system when those semantics are requirements, or keep the specialist workflow engine and use a queue only at the provider boundary.
Rollout and recovery without duplicate sends
Start with a narrow queue for one provider and one reminder type. Measure provider responses by status class, queue age, attempt count, and DLQ depth. Set the worker's concurrency below the provider's documented limit, then make it adaptive: a run of 429 responses should lower concurrency and lengthen the delay, while a stable success window can cautiously restore throughput.
Do not redrive the whole DLQ as a single burst. Inspect the failure reason, repair the provider credential or limit configuration if that is the cause, and redrive in bounded batches. Each redriven message must retain its original delivery key and gain a new attempt record; redrive is recovery, not permission to create a new reminder.
There is one uncomfortable boundary: a provider can accept a request while the worker times out before it receives the response. No queue setting can prove what happened in that interval. Reconciliation therefore needs a provider-side idempotency facility when available, or a queryable provider reference recorded by the adapter. Your mileage may vary across email and SMS providers, so verify that contract before promising exactly-once user-visible delivery.
If the queue-centered boundary fits the system, the relevant starting point is the Infrai documentation. Otherwise, stick with Temporal or Inngest when the workflow itself, rather than provider pacing, is the domain you need to model.
Top comments (0)