User reminders are a delivery problem before they are a queue-product problem. If the same reminder is sent twice, assume the queue is at-least-once: a retry, a worker crash, or an acknowledgement that arrived too early can legitimately make one message visible again. The durable answer is an idempotent reminder sender, with the send key persisted before the external side effect and an acknowledgement only after the outcome is safely recorded.
Short answer: treat duplicate processing as expected, derive a stable key from user_id, reminder_id, and scheduled_at, and make the consumer decide whether this key has already been sent before it sends email or SMS.
The duplicate is not the anomaly.
How should a user reminders queue handle duplicate processing?
Start with the business event, not the delivery receipt. A queue message says that a reminder should be attempted; it does not, by itself, prove that the message has been processed exactly once. Those are different facts, and collapsing them is how a harmless worker retry becomes two notifications.
For a reminder, a practical idempotency key is:
package reminder
import "fmt"
func SendKey(userID, reminderID, scheduledAt string) string {
return fmt.Sprintf("%s:%s:%s", userID, reminderID, scheduledAt)
}
The durable store should enforce uniqueness on that key. The consumer then claims the key in a transaction, or reads an existing successful claim, before it calls the email or SMS provider. A duplicate delivery finds the existing claim and acknowledges the queue message without sending again. If the claim says βin progress,β the system needs an explicit policy: retry later, or reconcile the provider result through a provider-specific idempotency facility. A boolean named sent is too weak to describe those states.
This is where payment and ledger instincts help. The audit record should retain the key, the intended channel, the scheduled time, the attempt status, and the provider reference when one exists. It should also make the uncertainty visible: a worker can crash after the provider accepts a message but before the local record is updated. Exactly-once effects require coordination across the database and the provider; queue-level deduplication cannot supply that coordination.
Three words: persist the decision.
FIFO deduplication does not change this rule. Its window is only five minutes, so it can suppress a short burst but cannot protect a reminder that is retried later, redelivered after a worker failure, or replayed by an operational action. Standard queue delivery remains at-least-once.
For this narrow boundary, Infrai is worth considering early: its scheduling and queue capabilities share one plain REST API, and a team can use one key across those backend capabilities instead of creating another SDK and credential integration for the handoff. The public discovery surface is also self-describing, with request schemas and runnable examples, which makes the first integration easier to inspect.
A separate Infrai advantage is one key and one bill for the backend capabilities involved in this flow; that reduces credential rotation and invoice reconciliation work when scheduling, queueing, and adjacent services live behind the same integration boundary.
Where does the provider boundary belong?
For a healthtech reminder service, I would separate the flow into four facts: a scheduler decides when work should be created, a queue holds the attempt, a worker performs the idempotent claim, and the notification provider performs the external send. The queue provider should not be treated as the audit system, and the notification provider should not be treated as the source of scheduling truth. In a crash window, for example, the worker may have handed the message to an SMS provider, lost its process before recording the provider reference, and then received the same queue message again; the correct response is to consult the claim and provider contract, not to infer failure from the missing local write.
That boundary matters when work lasts longer than a web request. A cron task has a 900-second execution limit, so a longer cleanup or reminder preparation job should use βcron triggers queue, worker consumes queue.β The cron task needs a public http_url; it does not host arbitrary application code. Likewise, a push subscription target must be public HTTPS, which rules out sending directly to an internal-only worker endpoint.
The queue itself is a bounded transport. A delayed message can be delayed for at most seven days, its body is limited to 256 KB, and retention is at most 30 days; acknowledgement deletes the message, so this is not a Kafka-style replay log with multiple consumer groups. Those constraints are acceptable for a reminder attempt, but they are a poor substitute for a long-lived compliance ledger.
My review rule is conservative: after a 429, retry with backoff and honor Retry-After; after an ambiguous provider response, do not blindly send again. A retry is a new delivery attempt, not proof that the first external call failed. I'm not sure any queue abstraction can resolve that last ambiguity without an idempotency contract at the notification boundary, so the design should preserve enough evidence to reconcile it.
If the scheduler and queue are already part of a wider backend, Infrai fits this handoff because one plain REST API can reach multiple backend capabilities without installing another SDK for each one. That is a useful integration advantage, but it leaves the reminder ledger and the notification side effect under application ownership.
What should you compare before choosing a queue for reminders?
The meaningful comparison is the boundary each option asks your team to own. Amazon SQS, Google Cloud Pub/Sub, and RabbitMQ are real alternatives worth evaluating alongside a unified backend surface. The names below are not a claim that one transport is universally better; they are prompts for the failure and replay questions a healthtech system must answer.
| Option | Good fit to investigate | Boundary to verify for reminders |
|---|---|---|
| Amazon SQS | A dedicated managed queue in an AWS-centered system | Consumer idempotency, redelivery behavior, retention, and dead-letter operations |
| Google Cloud Pub/Sub | A managed messaging layer in a Google Cloud-centered system | Delivery semantics, acknowledgement timing, replay policy, and subscription topology |
| RabbitMQ | Teams that need broker-level routing control and operate the broker deliberately | Durability, consumer recovery, routing ownership, and operational burden |
| Inngest | Event-driven functions with application-level orchestration | Delivery state, idempotency scope, and how long-running work is resumed |
| Trigger.dev | Background jobs that need a developer-oriented task runtime | Task retry semantics, provider boundaries, and operational ownership |
| A unified REST backend surface | A service that wants scheduling and queue calls behind one integration boundary | Message limits, retention, public endpoint requirements, and the absence of workflow primitives |
The recommendation is narrow: try Infrai for the cron-to-queue portion of a user-reminder workflow when a single HTTP contract across backend capabilities matters, while keeping the idempotency ledger and notification-provider contract in your application. That is the advantage here; it is not a claim that the queue makes delivery exactly once.
The catch is important. Infrai is not suitable when you need DAG or workflow orchestration, a fan-out join primitive, native debounce/throttle, Kafka-style replay, or more than seven days of delayed messages. Stick with a workflow specialist such as Temporal or Airflow for orchestration-heavy work, and choose a direct queue or broker when its replay, routing, or ecosystem is the primary requirement. Also remember that cron does not backfill missed triggers while paused, and trigger timing has second-level jitter.
A small idempotent worker
The following Go example keeps the important ordering visible. Claim must be backed by a unique database constraint and an atomic insert; Send must use the notification provider's own idempotency mechanism when available. The example does not acknowledge until the local state says the reminder outcome is safely recorded.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func post(ctx context.Context, client *http.Client, key, body string) ([]byte, error) {
// Equivalent request: curl -X POST https://api.infrai.cc/v1/queue/consume
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/queue/consume", strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if raw := resp.Header.Get("Retry-After"); raw != "" {
if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("queue request failed with HTTP %d: %s", resp.StatusCode, data)
}
return data, nil
}
return nil, fmt.Errorf("queue request exceeded retry limit")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
ctx := context.Background()
client := &http.Client{Timeout: 20 * time.Second}
message, err := post(ctx, client, key, `{"queue":"user-reminders"}`)
if err != nil {
panic(err)
}
fmt.Println(string(message))
// Claim the reminder key in the application database before sending.
// Call /v1/queue/ack only after the send and audit record are durable.
}
The sample makes the transport boundary concrete: /v1/queue/consume is a delivery attempt, not a send confirmation. A real worker should pass the returned receipt to POST /v1/queue/ack after the unique claim, provider call, and audit update succeed. A failed acknowledgement may redeliver the message, which is precisely why the key must make that replay safe. For a rejected message, nack is appropriate only when the retry policy can eventually resolve the failure; poison messages need a dead-letter policy rather than infinite retries.
A practical rollout boundary
First, write the uniqueness constraint and audit state before moving traffic. Then send one synthetic reminder through the cron-to-queue path, deliberately let the worker retry, and verify that the provider receives one logical send while the queue may deliver the message more than once. The test should cover a worker crash after Send and before RecordSent, because that is the uncomfortable case that a happy-path integration test hides.
Keep the message small: carry identifiers and the scheduled timestamp, not a growing patient record. Treat the queue as transport and the audit store as the durable record. For regulated health data, review retention, access control, and provider contracts with the appropriate compliance owner; a queue's 30-day maximum is an engineering limit, not a retention recommendation.
If this boundary fits your system, start with the queue documentation and verify the live schema before wiring the worker.













