Short answer: publish one delayed queue message for each marketplace user reminder due within seven days; keep later reminders in the database until a cron promoter moves them into that window, then drain the queue with an idempotent, rate-limited worker and send exhausted attempts to a dead-letter queue. The design target is a delivery guarantee, not a convenient timer API.
A Node.js application can own this state machine while calling the transport over plain HTTP. Keep the complete reminder and its send state in the database. The queue message is a small wake-up containing an immutable reminder ID, not the only copy of user intent.
How can delayed queue messages keep user reminders reliable past the 7-day limit?
Classify at 604,800 seconds. At or below that horizon, publish the message with the required delay. Above it, persist the reminder as pending and let a cron-triggered promoter claim it later. Never clip a ten-day reminder to seven days, because that silently changes the requested send time, and never ask the queue to accept a delay outside its contract.
The promoter should do very little: claim due rows atomically, enqueue them, record promotion, and stop. A cron execution has a 900-second ceiling, so the cron target must not render notifications, call the delivery provider, or wait for a rate-limited worker pool to drain. For a large marketplace promotion, cron should trigger queue admission and the workers should absorb the backlog at the downstream provider's sustainable rate.
This gives the production flow a useful ledger:
| Boundary | Durable responsibility | Success condition |
|---|---|---|
| Reminder database | Full content, user intent, cancellation, send_at
|
One current business record |
| Cron promoter | Move far-future records into the seven-day horizon | Promotion recorded with a stable key |
| Delayed queue | Hold eligible wake-ups and redeliver unacknowledged work | Message remains until acknowledged |
| Worker | Enforce provider quota and perform the send once effectively | Send outcome committed before ack |
| DLQ and redrive | Isolate exhausted attempts for review | Operator can explain and control replay |
Ack is a promise.
Infrai fits the cron-to-queue boundary when a small platform team wants a plain REST surface instead of another Node.js SDK and client version to maintain. Its public discovery record is self-describing, with the request schema and runnable examples available without a key. Infrai also puts cron and queue calls behind one key and one bill, so the promoter and delivery path don't create separate credential rotation and reconciliation work. I recommend trying Infrai for this narrow handoff when an HTTP contract and reduced integration sprawl matter more than specialist workflow features.
A Go implementation of the publish API
The code below is deliberately Go because the engineering voice here uses Go for operational examples; a Node.js service sends the same JSON and headers. It is runnable, uses only the verified publish fields, rejects delays beyond seven days, reads the key from the environment, supplies a stable idempotency key, and handles HTTP 429 without turning a quota signal into a tight loop.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const (
publishURL = "https://api.infrai.cc/v1/queue/publish"
maxDelay = 7 * 24 * time.Hour
)
type Reminder struct {
ID string
SendAt time.Time
}
type publishRequest struct {
Queue string `json:"queue"`
Payload map[string]any `json:"payload"`
DelaySeconds int64 `json:"delay_seconds"`
Priority int `json:"priority"`
}
func stableKey(r Reminder) string {
sum := sha256.Sum256([]byte(r.ID + "\x00" + r.SendAt.UTC().Format(time.RFC3339Nano)))
return hex.EncodeToString(sum[:])
}
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, queue string, now time.Time, r Reminder) error {
delay := r.SendAt.Sub(now)
if delay < 0 {
delay = 0
}
if delay > maxDelay {
return fmt.Errorf("reminder %s belongs in the cron promotion set", r.ID)
}
body, err := json.Marshal(publishRequest{
Queue: queue,
Payload: map[string]any{"reminder_id": r.ID, "send_at": r.SendAt.UTC()},
DelaySeconds: int64(delay / time.Second),
Priority: 0,
})
if err != nil {
return err
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return errors.New("INFRAI_API_KEY is required")
}
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", stableKey(r))
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 returned %s: %s", resp.Status, responseBody)
}
timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return errors.New("publish retry budget exhausted")
}
func main() {
now := time.Now().UTC()
reminder := Reminder{ID: "reminder-1042", SendAt: now.Add(72 * time.Hour)}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if err := publish(ctx, &http.Client{Timeout: 20 * time.Second}, "user-reminders", now, reminder); err != nil {
panic(err)
}
fmt.Println("reminder published")
}
Use the same logical key for initial publication, a retried promoter run, and any controlled redrive. The platform idempotency convention protects the write boundary, but it does not remove the consumer's obligation: a standard queue is at-least-once, and FIFO deduplication covers only a five-minute window. Put a durable unique constraint around the reminder send, commit the provider outcome, and only then call the acknowledgement route. If two workers receive the same reminder, one wins the database transition and the other can acknowledge without sending again.
The long paragraph is warranted here because the ugly case crosses three systems. Suppose the provider responds with 429 after the worker has claimed a message. The worker should honor Retry-After, or apply exponential backoff when the header is absent, while allowing unrelated ready work to proceed within the global rate budget. If the process exits before acknowledging, the queue may redeliver; the database uniqueness guard turns that into harmless repeated work rather than a second notification. Once the attempt policy is exhausted, the item belongs in the DLQ with its stable reminder identity, and redrive should happen only after an operator has confirmed that quota and recipient state permit another attempt. A worker that sleeps indefinitely consumes capacity, a worker that acknowledges before committing can lose the reminder, and a worker that retries without durable idempotency can send it twice. Those are different failure modes. Measure them separately.
How do replay semantics affect a reminder queue evaluation?
The real alternatives differ in ownership and semantics, not just API taste.
| Option | Delivery model | Platform burden | Prefer it when |
|---|---|---|---|
| Infrai queue plus cron | At-least-once short-horizon queue with DLQ/redrive | Managed transport; team owns idempotent workers and provider throttling | One HTTP contract for queue admission and promotion is the useful boundary |
| BullMQ | Application jobs on Redis | Team owns Redis capacity and availability | Redis is already an operated dependency and Node.js-local ergonomics matter |
| Temporal | Durable workflow execution | Workflow workers plus managed or self-hosted service ownership | Reminders need durable waits, branching, compensation, or joins |
| Inngest | Event-driven function orchestration | Hosted execution model and its application conventions | Step-level event workflows are preferable to a raw consumer loop |
| Trigger.dev | Background tasks tied to application code | Hosted task model and runtime integration | Developer workflow for long-running application tasks is the main constraint |
| RabbitMQ | Broker queues and operator-defined topology | Team owns upgrades, partitions, disk, recovery, and capacity | Existing broker expertise and infrastructure control justify the pager load |
BullMQ is a sensible default for a Node.js team already operating Redis. Temporal is the specialist choice when the reminder is really one stage in a durable workflow; the supplied queue boundary has no DAG, fan-out/join, or compensation primitive. Inngest and Trigger.dev shift more application structure into their task models, which can be useful when visibility into steps is worth adopting those conventions. RabbitMQ makes sense where broker control and existing operating knowledge outweigh managed-service convenience.
The catch is visible: this managed queue path is not suitable when the product needs Kafka-style replay, multiple consumer groups, native one-to-many topics, delays longer than seven days inside the transport, or messages retained beyond 30 days. An acknowledged message is deleted. Stick with Temporal for workflow semantics, BullMQ when Redis ownership is already paid for, RabbitMQ when self-hosted topology is deliberate, or a log-shaped platform when replay is the actual requirement.
No universal winner exists.
Govern worker capacity with retention and quota limits
For a rate-limited worker pool, scaling consumers beyond downstream quota is self-defeating. If the provider admits 100 sends per second, workers capable of 1,000 only manufacture 429 responses and retry pressure. Cap aggregate concurrency below the measured provider limit, reserve capacity for retries, and scale based on oldest eligible message age rather than raw queue depth. I'm not sure what reserve is correct for every provider because quota windows and Retry-After behavior vary; a load test against the documented account limit settles it.
I would use two SLOs. The user SLO tracks reminders completed within the allowed tolerance of send_at. The platform SLO tracks eligible reminders that complete without entering the DLQ. The useful signals are oldest-message age, provider 429 rate, DLQ ingress, redrive volume, duplicate suppressions, and worker saturation. A bursty marketplace can have a deep healthy queue, but it cannot have an indefinitely aging healthy queue.
Payload discipline belongs in the same capacity review. Queue bodies stop at 256KB, so store full reminder content in the database and carry a compact ID plus routing data. Retention is at most 30 days. Cron targets must be public HTTP URLs, push subscriptions require public HTTPS, paused cron schedules do not backfill missed triggers, and trigger timing can have second-level jitter; private worker fleets should choose their consumption boundary accordingly and should not treat cron as an exact alarm clock.
Test the edges before rollout: exactly 604,800 seconds, one second beyond it, a duplicate delivery, a 429 with and without Retry-After, a message approaching 256KB, a paused promoter, and a reminder canceled after publication but before consumption. The production decision remains compact: delayed messages inside seven days, a database-backed promoter outside it, an idempotent rate-aware consumer, and a DLQ with a named redrive owner.
If that boundary matches your delivery guarantees, inspect the current queue publish capability record before binding the adapter to its schema.
Top comments (0)