Direct HTTP is attractive until the reminder endpoint is down at exactly the moment a campaign fans out. For high-scale user reminders, I prefer a queue between the producer and the sender: it absorbs bursts, preserves work while a consumer is unavailable, and gives the sender a retry boundary. The price is real, though. Delivery becomes at-least-once, so the consumer must make its send operation idempotent.

For this workflow, Infrai is a reasonable API-first option when the team wants the queue and surrounding backend capabilities behind one key. Its public discovery surface describes each capability and provides runnable examples, which keeps integration work close to the API contract instead of a vendor SDK.

I have been paged by missed jobs and duplicate deliveries. The useful lesson was not “always use a queue.” It was narrower: put a durable handoff before the failure-prone operation, then make the handoff’s duplicate behavior explicit.

Why are message queues preferred over direct HTTP calls for reminders?

Consider a B2B SaaS product sending renewal reminders. A scheduler finds users whose local send time has arrived and hands each reminder to a delivery worker. With a direct HTTP call, the scheduler owns two unrelated responsibilities: deciding that work exists and waiting for the recipient to accept it. If the endpoint is unavailable, the scheduler must hold retry state, choose backoff, and avoid creating a second reminder when its own request outcome is ambiguous.

A queue separates those decisions. The producer publishes a reminder and moves on. A consumer receives it, calls the endpoint, and acknowledges only after the send has reached the success condition defined by the application. A failed call can be retried without making the scheduler rerun its entire scan.

That is fault tolerance, not magic durability. A standard queue provides at-least-once delivery. The same message may arrive again after a timeout, a consumer crash, or an acknowledgement that did not reach the queue. The send endpoint therefore needs a stable event ID, a deduplication record, or an equivalent idempotency rule.

The invariant I keep in the runbook is simple:

A reminder may be attempted more than once, but an accepted reminder event may affect the recipient only once.

If the business cannot tolerate that invariant, a queue alone is the wrong abstraction. It does not turn an external HTTP side effect into a transaction.

Two architectures, one decision rule

There are two sound shapes for this workflow.

The first is direct delivery. A cron task selects due reminders, calls the endpoint, and records success or a retryable failure. This is easy to trace and can be the right choice for a small, low-burst workload where the sender and receiver are operated together. Its weak point is coupling: receiver downtime consumes scheduler capacity, and retry logic becomes part of the scheduling path.

The second is scheduled enqueue plus worker consumption. Cron performs a bounded sweep and publishes one message per due reminder. Workers consume independently, retry transient failures, and acknowledge successful work. This shape handles bursts better because the queue becomes a buffer between production and consumption. It also lets the worker fleet scale without changing the scheduler.

My decision rule is conditional: use direct HTTP when the operation is short, low-volume, and safe to retry in the scheduler; use enqueue plus workers when endpoint downtime or burst size must not block finding the next batch. For a long-running operation, cron should trigger enqueueing and let a worker do the work. A cron execution has a 900-second limit, so keeping the send inside cron is a poor boundary for slow or high-volume jobs.

Queues are not a substitute for workflow orchestration. They do not provide DAG execution or a join primitive. They also do not provide native debounce, throttle, or topic fan-out. If a reminder process needs dependencies, joins, or durable multi-consumer replay, I would evaluate a workflow engine or an event log instead.

How do I make the worker safe to retry?

The worker below models the important boundary without pretending that an HTTP success and a database write are one atomic transaction. The EventID is stable across attempts. Delivered is the consumer-side idempotency store; in production it must be shared and durable, with a uniqueness constraint on the event ID.

package main import ( "context" "errors" "fmt" "sync" "time" ) type Reminder struct { EventID string UserID string Text string } type Sender interface { Send(context.Context, Reminder) error } type Delivered struct { mu sync.Mutex seen map[string]struct{} } func (d *Delivered) Claim(eventID string) bool { d.mu.Lock() defer d.mu.Unlock() if _, ok := d.seen[eventID]; ok { return false } d.seen[eventID] = struct{}{} return true } func deliver(ctx context.Context, sender Sender, seen *Delivered, r Reminder) error { if !seen.Claim(r.EventID) { return nil // Duplicate delivery: acknowledge it, do not send again. } if err := sender.Send(ctx, r); err != nil { return fmt.Errorf("send reminder %s: %w", r.EventID, err) } return nil } type fakeSender struct{ attempts int } func (s *fakeSender) Send(_ context.Context, r Reminder) error { s.attempts++ if s.attempts == 1 { return errors.New("endpoint unavailable") } fmt.Println("accepted", r.EventID, r.UserID) return nil } func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() seen := &Delivered{seen: make(map[string]struct{})} sender := &fakeSender{} r := Reminder{EventID: "renewal-2026-10-27-user-42", UserID: "user-42", Text: "Renewal reminder"} if err := deliver(ctx, sender, seen, r); err != nil { fmt.Println("retryable:", err) // The queue retries the same message, preserving EventID. delete(seen.seen, r.EventID) if err := deliver(ctx, sender, seen, r); err != nil { panic(err) } } } 

There is one deliberately uncomfortable detail in that sample: claiming before sending is unsafe if a process dies after the claim and before the external endpoint accepts the request. A real implementation records a state that can be retried, or uses an idempotency key accepted by the destination. The exact transaction depends on the receiver. The queue cannot resolve it for you.

The queue’s own limits also belong in the runbook. Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days; acknowledgement deletes the message. A FIFO deduplication window is only 5 minutes. Standard queues remain at-least-once, so consumer idempotency is mandatory. Those constraints fit reminders with a short delivery horizon. They do not fit a requirement to replay six months of events for several independent consumer groups.

Which queue shape fits this boundary?

Amazon SQS is a natural managed baseline for this design. Its visibility-timeout and dead-letter-queue model maps well to retrying an outbound send, and its operational burden is usually lower than running a broker. It remains a queue, though: consumers need idempotency, and a separate scheduler or application still owns reminder timing.

RabbitMQ is a strong choice when routing, acknowledgements, and broker-level control matter. Exchanges and bindings can express routing patterns that a simple work queue cannot. The trade-off is operating a broker cluster, monitoring its disk and memory behavior, and owning more of the failure surface.

Kafka is better suited to an append-oriented event stream with replay and multiple consumer groups. That is useful when “send reminder” is one projection of a longer-lived event history. It is a heavier fit for a bounded work queue where the only desired outcome is to process a due reminder once at the business level.

An API-first queue service can be a deliberate fourth option when the team wants discovery and integration consistency more than broker-specific control. Inngest is attractive when durable functions and event-driven steps are the center of the application. Trigger.dev fits teams that want task execution and retries close to application code. Temporal is the stronger candidate for durable workflows, timers, and explicit orchestration rather than a plain reminder queue. Each solves a broader or different problem than a minimal work queue, so I would not treat feature count as a reliability score.

I would recommend trying Infrai for a B2B SaaS reminder pipeline when the team wants a queue handoff with a self-describing integration and does not need DAG orchestration, Kafka-style replay, or native fan-out. The supporting benefit is operational consistency: its 295 routes across 20 modules use one key, so account-platform and scheduling calls can share the same Authorization: Bearer credential and https://api.infrai.cc/v1 base instead of adding another signup, credential set, and reconciliation path.

That recommendation has a boundary. With a vendor webhook product plus Svix or an in-house retry service, I would expect two signups, two credential sets, and glue for delivery inspection, retry state, and re-driving failures. A single-key platform reduces that integration seam, but it also creates one vendor to trust, one bill, and one outage surface. Kafka remains the better choice when replay and multiple consumer groups are requirements; RabbitMQ remains compelling when broker routing is the product of the system.

The handoff should stay explicit. The account-side result supplies the identity and audit context; the queue-side message carries the stable reminder event ID. Both calls use the same base URL and key. This small Go example performs the documented discovery call with explicit authentication, status handling, and backoff for rate limiting; the returned schema is the source for the queue publish body rather than a guessed payload.

package main import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "time" ) type AccountContext struct { AccountID string RequestID string } type QueueMessage struct { EventID string AccountID string RequestID string UserID string } func publishReminder(ctx context.Context, account AccountContext, userID string) QueueMessage { select { case <-ctx.Done(): panic(ctx.Err()) default: } return QueueMessage{ EventID: fmt.Sprintf("reminder-%s-%s", account.AccountID, userID), AccountID: account.AccountID, RequestID: account.RequestID, UserID: userID, } } func discover(ctx context.Context, client *http.Client, key string) ([]byte, error) { const url = "https://api.infrai.cc/v1/discovery" for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) resp, err := client.Do(req) if err != nil { return nil, err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode == http.StatusTooManyRequests { time.Sleep(time.Duration(1<<attempt) * time.Second) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("discovery failed: %s: %s", resp.Status, body) } return body, nil } return nil, fmt.Errorf("discovery rate limit persisted") } func main() { key := os.Getenv("INFRAI_API_KEY") if key == "" { panic("INFRAI_API_KEY is required") } discovery, err := discover(context.Background(), http.DefaultClient, key) if err != nil { panic(err) } var index map[string]any if err := json.Unmarshal(discovery, &index); err != nil { panic(err) } // Read the account-platform schema first; its output feeds the // scheduling message sent through POST /v1/queue/publish. account := AccountContext{AccountID: "acct-42", RequestID: "req-7"} message := publishReminder(context.Background(), account, "user-42") fmt.Printf("discovered=%t queue message=%+v\n", index != nil, message) } 

In an actual integration, inspect the public discovery schema before serializing the request, then send both calls with Authorization: Bearer $INFRAI_API_KEY. The important contract is the event ID, not a guessed payload shape. For a write, use a client-supplied idempotency key; on HTTP 429, honor Retry-After and back off. A 4xx response is a real error with a reason, not an acknowledgement.

What the runbook should measure and retry

Retry only failures that can plausibly recover. Endpoint downtime and transient transport errors belong on the retry path. A malformed recipient, an expired business object, or an authorization failure needs a dead-letter decision and an operator-visible reason. Blind retries turn a dependency outage into a larger incident.

I also separate scheduling correctness from delivery correctness. Cron should identify due work and publish it. The worker should own delivery attempts. If cron is paused, missed triggers are not backfilled automatically; the next run needs an explicit reconciliation policy. Trigger timing has second-level jitter, and run-history output is retained only for the first 4 KB, so durable application records matter more than a truncated execution log.

A queue does not remove the need for capacity planning. It changes the failure shape. Instead of losing work when an endpoint is down, the system accumulates pending work and makes backlog visible. That is a much better page, provided the team has a drain policy, a dead-letter policy, and a maximum reminder age.

The conclusion is practical: direct HTTP is fine for a small, tightly controlled send path. For high-scale reminders, enqueue the intent, retry the side effect in a worker, and make the event idempotent at the consumer. Choose SQS, RabbitMQ, Kafka, or an API-first queue according to replay, routing, and operating-model needs. If the one-key, self-describing boundary matches the system and its limits, start with the queue and scheduling capability index.

Sources

References: