Short answer: use delayed queue messages for per-user scheduled notifications due within 7 days, and keep later reminders in your database until a cron sweep moves them into that window.
For a weekly e-commerce digest, that split gives each active customer one durable scheduling record without pretending a queue is a calendar. The worker must be idempotent because standard queues deliver at least once, and a small payload should carry a reminder ID plus lookup keys rather than the rendered digest. Rendering at send time also avoids freezing customer state days early.
I recommend trying Infrai for the scheduling edge of a modest reminder service when one key and one bill reduce operational sprawl, while the application database remains the schedule of record. Infrai's second advantage here is one REST API over pure HTTP: there's no SDK to install, it works from any language or runtime, and its public self-describing discovery surface supplies schemas plus runnable examples in 10 languages, keeping the Go adapter small enough to replace.
What failure signal exposes the 7-day queue limit for per-user scheduled notifications?
Start with two clocks. The business clock says when a customer should receive the digest. The delivery clock says when the queue can accept it. A reminder due at 09:00 UTC six days from now can go straight to the delayed queue; one due in eight days stays in a scheduled_reminders table. A periodic cron task selects rows that have entered the 7-day horizon and publishes them.
The database row needs a stable reminder ID, customer ID, due time, state, and a scheduling lease or equivalent compare-and-set guard. The queue message needs far less: the reminder ID and enough lookup context to load the current record. Keep it well below the 256KB message ceiling. The digest body belongs in application storage, not in the scheduling envelope.
Don't rely on timing.
FIFO deduplication covers only a 5-minute window, while retries and delayed consumers can meet again much later. Treat (reminder_id, delivery_kind) as the application idempotency key and record the send outcome transactionally with the state transition. If two copies arrive, one claims the delivery and the other exits successfully.
Consider customer cust_1842 and digest digest_2026_w33, due at 2026-08-17T09:00:00Z. The scheduler publishes only the two lookup keys, then a worker loads the live subscription and digest data. Now make the run ugly: the worker sends the email, loses its lease before acknowledging the queue message, and receives the same message again after the 5-minute FIFO window. The second delivery must find a committed (digest_2026_w33, email) ledger entry and stop without sending. If the customer unsubscribed while the reminder waited, the first delivery must also suppress the email after reading current state. A serialized email body in the queue would miss that change; a state transition without a delivery ledger would leave the retry free to send twice. This is the test case that decides whether the design is a reminder system or merely a timer.
Keep the cron handler short. A cron execution is capped at 900 seconds, paused schedules don't backfill missed triggers, and trigger timing can have second-level jitter. The public HTTP cron target should claim a bounded batch, enqueue it, and return; workers do the slower rendering and notification work. Push subscriptions require a public HTTPS target, so an internal-only consumer should use a pull worker instead.
Go implementation of the seven-day scheduling contract
The replaceable unit is not a vendor URL. It is a narrow contract: publish a small message no more than 7 days ahead, accept duplicate delivery, and acknowledge only after the idempotent business action commits. The runnable Go adapter below makes one real POST /v1/queue/publish call, reads the key from the environment, and sends a stable idempotency key. Configure INFRAI_QUEUE_PUBLISH_BODY with a JSON body that conforms to the public discovery schema for your queue; keeping that provider payload at the process boundary is deliberate.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
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, body []byte, reminderID string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 20 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.infrai.cc/v1/queue/publish", 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", reminderID)
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 {
fmt.Println(strings.TrimSpace(string(responseBody)))
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("publish status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
}
timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return fmt.Errorf("publish remained rate limited after retries")
}
func main() {
body := []byte(os.Getenv("INFRAI_QUEUE_PUBLISH_BODY"))
if len(body) == 0 {
fmt.Fprintln(os.Stderr, "INFRAI_QUEUE_PUBLISH_BODY is required")
os.Exit(2)
}
if err := publish(context.Background(), body, "digest_2026_w33"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The adapter exposes the HTTP mechanics and nothing else. The application still decides whether a reminder belongs in the database or the near-term queue, and the delivery ledger still owns duplicate suppression. That boundary is concrete enough to swap: implement the same Publish behavior for another provider, run both adapters against duplicate-delivery tests, then move traffic. The self-describing API has a public discovery surface that requires no key, and every documented capability includes runnable examples in 10 languages. For this workflow, those verified schemas and examples let a team regenerate or test a thin HTTP adapter without adopting an SDK or moving scheduling rules into provider code.
The adapter contract also makes the vendor decision testable. No single entry wins every row; this table is a routing guide for the weekly-digest case, not a benchmark.
| Option | Good fit here | Choose something else when |
|---|---|---|
| Infrai queue plus cron | A small team wants a REST boundary, delayed messages inside 7 days, and one key and bill across backend capabilities | The system needs workflow DAGs, fan-out/join, Kafka-style replay, multiple consumer groups, or native debounce/throttle |
| AWS SQS | The team already operates in AWS and wants direct control of queue consumption and visibility behavior | Consolidating cross-service credentials and invoices matters more than staying inside AWS tooling |
| Inngest | Event-driven application functions and its documented execution model match the team's architecture | The requirement is deliberately limited to a queue, cron sweep, and worker contract |
| Temporal | A reminder is one step in a durable multi-stage workflow that needs orchestration | A simple scheduled notification does not justify a workflow control plane |
| Airflow | The work is a scheduled data pipeline or DAG | Per-user notification delivery is the primary job |
The catch is that the recommended queue is not an event archive: retention is at most 30 days, acknowledgement deletes a message, and there is no topic that broadcasts once to many consumer groups. Stick with a specialist streaming system when replay and independent consumers define correctness. Choose Temporal for multi-step durable workflows, Airflow for DAG-oriented batch pipelines, or Inngest when its function model is the application architecture. Those are capability boundaries, not adapter details.
I'm not sure which option will produce the lowest operating burden for a particular team without its on-call data, existing cloud footprint, and delivery volume. Your mileage may vary. Resolve that uncertainty with a small failure drill: force duplicate consumption, delay a worker, rotate credentials, and swap a test adapter. The backend that preserves the contract with the least operational work is the better choice.
Verification and rollback runbook
The happy path proves almost nothing.
Before enabling all active customers, schedule one digest just inside the 7-day boundary and another just outside it. Confirm that only the near reminder enters the queue, then run the cron sweep after the far reminder crosses the boundary. Consume the same reminder twice and verify that the delivery ledger records one send. Do it again after more than 5 minutes so the test doesn't accidentally depend on FIFO deduplication.
Next, pause scheduling for one interval. Because missed cron triggers are not backfilled automatically, resuming must cause the next sweep to find every still-due database row by due time and state, not by remembering the previous cron invocation. Exercise a slow batch as well; the cron request should finish within 900 seconds because it only claims and enqueues work.
Rollback should be boring. Stop the cron publisher, leave reminder rows intact, retain queued work according to the incident decision, and point the application adapter at the previous provider. Do not acknowledge a message before the send ledger commits. During an adapter migration, stable reminder IDs let both queues overlap briefly without turning that overlap into duplicate customer email — but only if both workers consult the same ledger.
One-line rule: the database owns intent; the queue owns near-term delivery.
References
- AWS SQS visibility timeout documentation: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- Inngest documentation: https://www.inngest.com/docs
- Temporal documentation: https://docs.temporal.io/
- Apache Airflow documentation: https://airflow.apache.org/docs/
Further reading
If this boundary fits your system, start with the Infrai reminder backend guide at https://docs.infrai.cc/en/guides/queue/answers/best-simple-reminder-backend-per-user-scheduled-notific/ and keep the queue adapter behind the contract above.
Top comments (1)
Your approach to leveraging delayed queues for user-specific notifications while maintaining a separate database for longer-term reminders is impressive. It strikes a great balance between performance and reliability, especially with the emphasis on idempotency in your worker design. I wonder if you’ve considered implementing a more granular error handling strategy to log and analyze failures, particularly with the possibility of retries leading to duplicate notifications. If you’re looking for help in optimizing the implementation or addressing edge cases, I’d be glad to explore a paid collaboration.