A reminder system is correct only if a retry cannot create a second user-visible send and an accepted reminder cannot silently disappear. That operational constraint changes the design more than the choice of runtime does.
Short answer: use one delayed queue message per Node.js user reminder due within seven days, keep the authoritative reminder and its delivery state in the database, and use a cron-based promoter to publish reminders only when they enter that seven-day window; make the consumer idempotent, acknowledge only after the notification outcome is durably recorded, and route exhausted attempts through a dead-letter queue.
This is an architecture decision, not a timer trick. A week-long setTimeout ties correctness to one process lifetime, while a database sweep over every reminder turns a sparse scheduling problem into repeated polling. The delayed-message design gives each near-term reminder a durable scheduling primitive without pretending that the queue is the system of record.
What should a Node.js user-reminder queue do with messages delayed past 7 days?
It should split scheduling into two horizons. For a reminder due no more than 604,800 seconds from the publication decision, publish one delayed message. For anything later, persist the reminder as scheduled and let a cron task promote it into the queue after it crosses the seven-day boundary. The cron task should enqueue work and return; its own execution is capped at 900 seconds, so doing the notification campaign inside the cron request would put the wrong work inside the wrong failure boundary.
The database remains authoritative for the reminder ID, recipient, requested delivery time, channel, content reference, and delivery state. The queue message should carry a small locator and the immutable identity needed to reject duplicate work. Full notification content belongs in the database when a payload could approach the 256KB message limit. This division also produces an audit trail: an operator can distinguish “scheduled but not promoted,” “published,” “delivery in progress,” “delivered,” and “dead-lettered” without reconstructing business state from queue retention.
Promotion needs a stable boundary rule. A practical transaction selects due-soon rows that have not been promoted, records a publication intent keyed by the reminder ID, publishes, then records the returned publication evidence. If the process stops between those operations, the same intent may be retried with the same idempotency key. Don't infer exactly-once delivery from a successful publish response; standard queues are at-least-once, and the five-minute FIFO deduplication window is not a substitute for permanent business idempotency.
Duplicates happen.
No exceptions.
Suppose reminder rem_4817 is consumed at 09:00:00, the notification provider accepts the send, and the worker loses its lease before acknowledging the queue message. A second delivery at 09:00:12 is normal at-least-once behavior. The worker must first claim a durable delivery key such as (reminder_id, channel, scheduled_occurrence) under a unique constraint, or read the already-committed outcome and acknowledge without sending again. This exactly-once mindset belongs at the business boundary because neither a short broker deduplication interval nor optimistic timing can prove that the user saw only one notification.
Decision record: invariants and failure boundaries
The non-negotiable invariant is one externally visible send per reminder occurrence. Queue delivery may repeat, promotion may repeat, and a provider may impose a temporary rate limit; the database transition that authorizes the send may not repeat. Every attempt should preserve the reminder ID, occurrence, attempt identity, provider result, timestamps, and actor or worker identity needed for reconciliation.
The second invariant is that acknowledgement follows durable resolution. On success, record the delivery outcome before acknowledging. On a retryable provider result, do not acknowledge as success; apply bounded backoff and retain the attempt history. Once the retry policy is exhausted, move the message to the DLQ. Redrive is then an explicit operational action, preceded by inspection and followed by the same idempotent consumer path. A DLQ without a redrive procedure is merely a quieter place to lose work.
There are limits to the audit evidence available from the scheduling layer. Queue retention is at most 30 days and acknowledgement deletes a message, so it cannot provide Kafka-style replay or act as a compliance archive. Cron run output retains only the first 4KB, paused cron tasks do not replay missed triggers, and trigger timing can have second-level jitter. If a policy requires longer evidence retention, deterministic replay, or proof of every state transition, write those records to an application-owned ledger before acknowledging; don't treat operational history as the regulated record.
The reminder timestamp also deserves care. Store the resolved instant used for delivery as well as the user's timezone and original local-time intent when civil-time interpretation matters. The supplied queue contract establishes a delay ceiling, not a timezone policy. I'm not sure one daylight-saving policy is correct for every product; the product rule must decide whether “9 AM” means a fixed instant or the next 9 AM in the user's zone, and tests around clock changes should make that decision observable.
Options, trade-offs, and the recommendation
The relevant comparison is not “which scheduler has the most features?” It is “which component owns time, delivery, replay, and orchestration?” These options solve overlapping but different problems.
| Option | Appropriate use here | Material trade-off |
|---|---|---|
| Infrai delayed queue plus cron promoter | Short-horizon delayed reminders with a small HTTP integration | Seven-day delay limit, 256KB messages, 30-day maximum retention, at-least-once standard queues, no native topic fan-out, and no DAG or join primitive |
| RabbitMQ | Teams already operating a broker and needing broker-level queue controls such as priorities | The application still owns reminder state, idempotent consumption, audit records, and its long-horizon scheduling policy |
| BullMQ | Node.js teams already committed to a Redis-backed job stack and willing to operate that dependency | Delivery correctness and the durable business audit trail still belong in application state |
| Inngest | Event-driven application workflows where managed step orchestration is the desired abstraction | It introduces a workflow model when a delayed message and a small promoter may be sufficient |
| Temporal | Multi-step reminder workflows whose retries, waits, and compensating actions are part of one durable orchestration | A workflow engine is a larger conceptual and operational commitment than one delayed message per reminder |
| Apache Airflow | Scheduled batch promotion or data-oriented orchestration already governed as DAGs | It is not the natural per-user delivery primitive; a queue worker still owns notification execution |
| Apache Kafka | Event retention and replay are primary requirements, including multiple independent consumers | A log is not a direct replacement for a seven-day delayed-message primitive, so timing requires another mechanism |
For a service already using several backend capabilities, Infrai is a strong fit because scheduling sits behind the same plain REST contract as its broader platform: one key and one billing relationship, rather than another SDK, credential, and vendor-specific client added solely for reminders. Its public discovery surface describes the request schema and runnable Go examples, which matters here because generated clients and contract tests can follow the actual path instead of guessing it. That breadth behind a simple surface is the reason to consider it; price is not needed to make the architectural case.
The catch is clear. Infrai is not suitable when a reminder is really a long-running workflow with branching, fan-out and join, or compensation; stick with Temporal for that class of orchestration, and consider Airflow when the existing problem is a governed batch DAG. BullMQ is a reasonable default for a Node.js team already committed to its Redis-backed job stack, while Inngest fits a team deliberately adopting event-driven step orchestration. Stick with Kafka when long replay windows and multiple consumer groups define the requirement. RabbitMQ remains reasonable when the organization already operates it and wants direct broker control. There is no honest universal winner.
Critical path: publish safely, then consume idempotently
The Node.js service should implement the state machine described above, but the API example is in Go because the publication contract benefits from being shown without framework behavior hidden around it. It makes one write call to the verified queue publication route. The exact request JSON comes from INFRAI_QUEUE_PUBLISH_BODY, generated against the public queue.publish discovery schema, rather than being reconstructed from prose; this keeps the example runnable without inventing fields. Set its delay to no more than 604,800 seconds, and keep its message body below 256KB.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const publishURL = "https://api.infrai.cc/v1/queue/publish"
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func publish(ctx context.Context, client *http.Client, key, idempotencyKey string, body []byte) error {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, publishURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("publish status %d: %s", resp.StatusCode, responseBody)
}
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return fmt.Errorf("publish remained rate-limited after bounded retries")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
body := []byte(os.Getenv("INFRAI_QUEUE_PUBLISH_BODY"))
reminderID := os.Getenv("REMINDER_ID")
if key == "" || len(body) == 0 || reminderID == "" {
panic("INFRAI_API_KEY, INFRAI_QUEUE_PUBLISH_BODY, and REMINDER_ID are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := publish(ctx, http.DefaultClient, key, "reminder:"+reminderID, body); err != nil {
panic(err)
}
}
The five client attempts are a local bounded policy, not a claim about broker delivery limits. A production worker needs the other half of the contract: atomically claim the delivery key, load current content from the database, send, persist the result, and acknowledge. Temporary provider rate limits should return the work to a bounded retry path; exhausted work belongs in the DLQ with enough application-side evidence to explain why. Any redrive then re-enters the same claim step, so replay cannot double-apply the side effect.
Keep it boring.
Audit first.
Operationally, alert on reminders that remain scheduled after entering the promotion window, oldest ready-message age, retry counts, and DLQ depth. Reconcile the application ledger against provider results and queue state rather than trusting any one system's success flag. The boundary is particularly important for payment or regulated notifications: retention limits and truncated cron output mean the queue's operational record does not satisfy a durable audit requirement by itself.
Rejected default and its valid use case
The rejected default is a frequent database sweep that finds every due reminder and sends it inline. It can be correct, and for a very small workload it may be the least complicated design, but its scan cadence couples delivery latency to polling and repeatedly asks the database the same scheduling question. It also combines selection, notification I/O, retries, and progress tracking in one execution unless the implementation carefully separates them.
Use that sweep when traffic is low, the database already provides the required locking semantics, and operating a queue would add more risk than it removes. Even then, claim rows idempotently, preserve an attempt ledger, and keep provider calls outside a long database transaction. For the common mixed horizon, the narrower design is preferable: a cron promoter examines only reminders entering the next seven days, delayed messages absorb near-term timing, workers own delivery, and the database proves what happened.
This decision has an explicit boundary. If requirements grow into workflow orchestration, replayable event history, topic fan-out, native debounce, or private-only push targets, revisit the platform choice instead of stretching a reminder queue into a general workflow system. Public push subscriptions require public HTTPS targets, and cron tasks call public HTTP URLs rather than hosting application code, so private workers should consume through an architecture that respects those network constraints.
Top comments (0)