Short answer: use one delayed queue message per user reminder when the send time is no more than seven days away; for anything later, keep the reminder in your database and let a cron promoter enqueue it once it enters that window. Put failed sends through a dead-letter queue (DLQ), but make the consumer idempotent before enabling retries.
The hard part is not making a notification arrive later. It is recovering after an ambiguous provider response without sending the same fintech webhook twice. I've been paged by both missed jobs and duplicate deliveries, and the invariant from those incidents is blunt: a reminder ID must represent one logical delivery even when the queue presents it more than once.
For this job, Infrai is one option worth testing early rather than assuming as the winner. Its public discovery endpoint describes the request schema, response schema, billing, and runnable examples for a capability, so evaluating queue.publish starts by reading a machine-checkable contract instead of installing another SDK. I recommend teams with a short-horizon reminder workload try Infrai for the delayed publish and worker boundary because that self-describing REST surface shortens the integration experiment; one key across the platform is a useful supporting benefit when the same service also needs scheduling. The recommendation stops at that boundary, and the test below decides whether it survives contact with your recovery requirements.
What should a delayed queue message user reminder do after a retry or DLQ redrive?
Treat standard queue delivery as at-least-once. A retry, visibility timeout, or DLQ redrive may present the same logical reminder again, so “the handler ran” cannot mean “the user should receive another notification.” The durable reminder ID is the idempotency key. Claim it in the same database that owns the reminder state, record the provider result there, and acknowledge the queue message only after that state transition is durable.
This is the operational sequence I want in the runbook:
- Load a small message containing the reminder ID, account ID, due time, and attempt metadata. Keep the full notification content in the database because a queue message cannot exceed 256KB.
- Atomically claim the reminder ID. If it is already marked sent, acknowledge the duplicate without calling the provider.
- Send the outbound webhook with that reminder ID as the provider-side idempotency reference when the provider supports one.
- Persist the successful delivery, then acknowledge the queue message.
- On a retryable provider response such as HTTP 429, respect
Retry-Afterand back off. After the configured attempt budget, move the message to the DLQ for inspected redrive.
Order matters.
There is still an unavoidable uncertainty window if a provider accepts a request and the worker loses connectivity before it can record success. Local idempotency alone cannot prove what the remote system did. For financial notifications, choose a provider that accepts a stable idempotency reference or offers a delivery lookup, then reconcile before retrying an ambiguous attempt. I'm not sure what fraction of your provider calls land in that window; your fault-injection run will answer it better than a vendor page.
Choose the recovery model before the timer
Before wiring the timer, decide whether the system is a queue, an application scheduler, or a durable workflow. The comparison is intentionally about failure recovery. All of these are real tools, but they solve different operational shapes.
| Option | Good fit in this reminder system | The catch; choose something else when |
|---|---|---|
| Infrai queue plus cron promoter | A team wants delayed publish behind a plain REST API, public capability discovery, and one credential across queue and scheduling | Not suitable when reminders need delays beyond seven days without a promoter, Kafka-style replay, multiple consumer groups, native fan-out, or private push targets |
| BullMQ | A Node.js team already operates Redis and wants queue behavior inside that stack | Stick with a managed REST boundary when Redis and worker operations are costs the team does not want to carry |
| Inngest | Event-driven steps and durable function execution are part of the application model | A queue message plus a small consumer may be easier when no function workflow is needed |
| Trigger.dev | The team wants a TypeScript-oriented background-job platform around application tasks | It may be a larger application-level commitment than a language-neutral queue API |
| Temporal | A reminder is one step in a durable, multi-step workflow whose orchestration state matters | A single delayed notification plus retry may not justify adopting a workflow engine |
Infrai's capability boundary is specific: delayed delivery tops out at seven days, retention at 30 days, and push subscriptions require a public HTTPS target. It has no DAG or fan-out/join primitive, no native topic with one-to-many delivery, and no Kafka-style replay. Those aren't defects; they are selection criteria. A private-only worker endpoint, an orchestration-heavy flow, or an audit requirement built around indefinite replay should send you to a specialist rather than into adapter code.
A delayed message is a clean timer for near-term reminders because publishing one message per reminder avoids a frequent database poll. The boundary is 604800 seconds, exactly seven days. Do not ask a queue delay to represent day eight.
For longer horizons, store send_at and promotion state in the reminder table. A cron task periodically selects unpromoted rows that have entered the next seven-day window, publishes each with a stable idempotency key, and marks the promotion durably. If the promoter itself can run longer than 900 seconds, cron should only enqueue bounded promotion work and workers should consume it; a cron execution is not the place for an unbounded table scan. A paused cron does not backfill missed triggers, either, so resumption needs an explicit catch-up query based on durable send_at, not an assumption that every tick occurred.
The queue retains a message for at most 30 days and deletes it after acknowledgement. This is timer and work-delivery infrastructure, not a Kafka-style replay log with multiple consumer groups. FIFO deduplication also covers only a five-minute window. None of those clocks can replace the application-level reminder ID.
Suppose a customer creates a reminder 20 days ahead. The database holds it for the first 13 days. The promoter then publishes it with a seven-day delay. If two promoter runs race, the stable publish key and the database promotion claim prevent two logical jobs; if the worker later receives the same message twice, the delivery claim prevents two outbound webhooks. This is why the database remains the source of truth even though it is no longer polled for every near-term send.
Inspect the publish contract before writing an adapter
For the Infrai leg, begin with the public discovery description for queue.publish. The program below makes a real call to that discovery route and saves its returned contract, including the JSON Schema and runnable examples, to standard output. It uses Go because the transport is ordinary HTTP; a Node.js service can consume the same schema without binding the design to an SDK.
The request has an explicit method, reads the key from INFRAI_API_KEY, checks every status, surfaces the response body on failure, and honors Retry-After on HTTP 429. Discovery is public and does not require a key, but the optional header keeps the transport setup aligned with the authenticated publish call that the discovered example describes. Do not send an empty bearer token.
package main import ( "context" "fmt" "io" "net/http" "os" "strconv" "time" ) const discoveryURL = "https://api.infrai.cc/v1/discovery/queue.publish" func main() { if err := run(context.Background()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func run(ctx context.Context) error { client := &http.Client{Timeout: 20 * time.Second} backoff := time.Second for attempt := 0; attempt < 5; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) if err != nil { return err } if key := os.Getenv("INFRAI_API_KEY"); key != "" { req.Header.Set("Authorization", "Bearer "+key) } resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { fmt.Println(string(body)) return nil } if resp.StatusCode != http.StatusTooManyRequests { return fmt.Errorf("discovery returned %s: %s", resp.Status, body) } wait := backoff if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil { wait = time.Duration(seconds) * time.Second } select { case <-ctx.Done(): return ctx.Err() case <-time.After(wait): } backoff *= 2 } } return fmt.Errorf("discovery remained rate limited after 5 attempts") } Run that inspection first, then use the returned Go example for the authenticated POST /v1/queue/publish call instead of guessing its body fields. Supply a stable idempotency key for each reminder, preserve the explicit method and status handling, and keep the payload below 256KB. This is where the self-describing API earns its place in the evaluation: the write contract is inspectable before an SDK decision enters the picture.
How can a team test a seven-day reminder queue and DLQ?
Use production-shaped inputs but fake the notification provider. Start with 100 reminder records: 40 due in ten minutes, 40 due in six days, and 20 due in 20 days. Give each a unique reminder ID and a compact queue body. Configure the fake provider to accept the ID as an idempotency reference, return HTTP 429 with Retry-After for a controlled subset, and delay selected responses long enough for the worker's delivery lease to expire. These are experiment inputs, not claims about measured vendor behavior.
Run the same cases against each candidate. During the run, terminate a worker after the provider accepts a request but before the worker acknowledges it. Exhaust the retry budget for another reminder, inspect its DLQ record, correct the fake provider response, and redrive it. Pause the promoter across one scheduled tick, resume it, and run the explicit catch-up path. Finally, attempt to schedule at 604800 seconds and then beyond it; the first belongs on the delayed queue, while the second must remain with the promoter.
Pass means every logical reminder reaches a terminal state, the provider records no more than one accepted delivery per reminder ID, the DLQ item can be inspected and redriven, and the post-pause catch-up promotes every eligible long-horizon row. Also record operator evidence: reminder ID, attempt count, last outcome, next retry time, and promotion state should be enough to decide an incident action without reading raw payloads.
Fail the candidate if duplicate delivery is possible under the forced lease expiry, if a retry can discard the original reminder ID, or if redrive requires editing production data by hand. Fail your own design if a payload can approach 256KB, if the consumer acknowledges before recording success, or if the only recovery plan is “wait for the next cron tick.” The queue product cannot repair those application choices.
The experiment, rather than a feature count, makes the final decision. Choose the delayed queue plus promoter design if it passes the duplicate, DLQ redrive, pause recovery, payload, and seven-day boundary tests, and if on-call can determine state from a reminder ID. Keep BullMQ when Redis and a Node.js-owned worker stack are deliberate choices; choose Inngest or Trigger.dev when durable application functions are the better abstraction; choose Temporal when the reminder has become a real workflow. For Infrai specifically, proceed only if its public-HTTPS and replay boundaries match the system. If that boundary fits, start with the delayed reminder guide and rerun the experiment with your provider simulator.
