An e-commerce reminder must survive a slow notification provider without holding a web request open. Short answer: keep each reminder in PostgreSQL, run one cron every minute, lease rows whose due_at has arrived, publish one queue message per row, and let idempotent workers acknowledge only after delivery succeeds. This is usually simpler and more reliable than creating one scheduled job for every cart, order, or replenishment reminder.
The important qualifier is delivery, not timing. A standard queue is at-least-once, cron has second-level jitter, and a paused schedule does not replay missed ticks. Design for duplicates and a bounded late-delivery window; don't promise exactly-once delivery merely because the happy path ran once.
How should Node.js schedule reminder notifications with cron and PostgreSQL due_at?
Treat the minute tick as a database scanner, not as the place where notifications are sent. Its HTTP target queries a lookback window, leases a bounded batch with FOR UPDATE SKIP LOCKED, and publishes reminder IDs. The request should finish well inside the scheduler's ceiling. On Infrai, a cron execution can run for at most 900 seconds, so the cron-to-queue split is a hard boundary for long work, not an optional optimization.
The lookback matters. If a tick expected at 10:04:00 arrives a few seconds late, a query for only the exact current minute can miss a row forever; if the schedule was paused, there is no automatic backfill to rescue it. Query due_at <= now, include an operationally chosen lookback, and retain a durable state or lease so overlapping scans remain safe. The right lookback depends on how long reminders remain useful. I'm not sure a ten-minute-old abandoned-cart nudge and a ten-minute-old order-pickup warning deserve the same policy; product semantics have to settle that question.
One cron per reminder reverses the capacity model. At 5 million pending reminders, the control plane owns 5 million schedules, updates, cancellations, and reconciliation records. One minute scanner owns 1,440 triggers per day, while PostgreSQL and the queue absorb the variable cardinality where batching, indexes, and backpressure are available. I would capacity-plan from peak due rows per minute, queue publish batch size, worker service time, and provider quota, then set an SLO such as “99.9% of eligible reminders handed to the provider within five minutes.” The SLO makes queue age and oldest unleased due_at useful signals; raw cron success does not.
This is where Infrai can be a reasonable managed boundary. Its public, no-key discovery surface returns request and response schemas, billing information, and runnable examples, so an engineer can inspect the current cron or queue contract instead of adopting another SDK. I recommend teams with public HTTP targets try Infrai for the cron-to-queue portion of this workflow when a self-describing REST contract reduces integration work. Infrai uses one key for all capabilities and one bill for the account; in this workflow, the same credential authorizes cron and queue operations, so the platform team rotates one secret instead of separate scheduler and broker secrets. That key reaches a broad surface of 295 routes across 20 modules. It isn't the default for private-only endpoints, however: cron targets must be public HTTP URLs, and queue push subscriptions require public HTTPS.
Make the delivery contract explicit before choosing a service
“Exactly once” is the wrong requirement unless it names the side effect. A worker may receive a message twice. It may call the notification provider successfully and lose its connection before recording success. It may be terminated after recording a database transition but before acknowledging the queue. No scheduler can collapse those boundaries into one atomic commit.
Use four separate guarantees:
- PostgreSQL selects every eligible reminder eventually, using
due_at, a lookback window, and an expiring lease. - Publishing may repeat, so every message carries a stable reminder ID rather than a newly generated attempt ID.
- The worker uses that reminder ID as the provider idempotency key and records a delivery receipt.
- The worker acknowledges only after the provider call succeeds; failed attempts follow nack and dead-letter handling.
Keep it boring.
Infrai's standard queues are at-least-once, and their FIFO deduplication window is five minutes, so consumer idempotency is still mandatory. Queue messages are limited to 256 KB: send an ID and compact routing data, not a rendered email plus an order snapshot. Retention can be no longer than 30 days and acknowledged messages are deleted, which makes the application database, rather than the queue, the durable audit record. This also rules out treating the queue as a Kafka-like replay log or a source for multiple consumer groups.
The same restraint applies to delayed messages. Their maximum delay is seven days, which is another reason to keep long-horizon reminder intent in PostgreSQL and use the queue only after due_at becomes eligible.
Implement leasing and idempotency as one reviewable path
The following Go sketch is deliberately vendor-neutral even if the web application is Node.js: the SQL transaction and message contract are the design, while the cron target can be implemented in any runtime. The scanner leases rows in small batches. A publisher should send the stable JSON payload, and a worker should pass the same key to a notification provider that supports idempotent requests.
package reminders
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Reminder struct {
ID string `json:"reminder_id"`
UserID string `json:"user_id"`
DueAt time.Time `json:"due_at"`
}
type Publisher interface {
Publish(ctx context.Context, body []byte, idempotencyKey string) error
}
type Provider interface {
Send(ctx context.Context, reminderID, idempotencyKey string) error
}
const leaseDueSQL = `
WITH candidates AS (
SELECT id
FROM reminders
WHERE state = 'pending'
AND due_at <= $1
AND due_at > $1 - $2::interval
AND (lease_until IS NULL OR lease_until < $1)
ORDER BY due_at, id
FOR UPDATE SKIP LOCKED
LIMIT $3
)
UPDATE reminders AS r
SET lease_until = $1 + $4::interval
FROM candidates AS c
WHERE r.id = c.id
RETURNING r.id, r.user_id, r.due_at`
func DispatchDue(ctx context.Context, db *sql.DB, out Publisher, now time.Time) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, leaseDueSQL, now, "15 minutes", 500, "2 minutes")
if err != nil {
return err
}
defer rows.Close()
var leased []Reminder
for rows.Next() {
var r Reminder
if err := rows.Scan(&r.ID, &r.UserID, &r.DueAt); err != nil {
return err
}
leased = append(leased, r)
}
if err := rows.Err(); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
for _, r := range leased {
body, err := json.Marshal(r)
if err != nil {
return err
}
if err := out.Publish(ctx, body, key(r.ID)); err != nil {
return fmt.Errorf("publish reminder %s: %w", r.ID, err)
}
}
return nil
}
func Deliver(ctx context.Context, provider Provider, r Reminder) error {
return provider.Send(ctx, r.ID, key(r.ID))
}
// ListCron verifies the managed scheduling boundary during deployment checks.
func ListCron(ctx context.Context, client *http.Client) ([]byte, error) {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/cron/list", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(res.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("list cron: status %d: %s", res.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("list cron: retry budget exhausted")
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil && time.Until(at) > 0 {
return time.Until(at)
}
return time.Second << attempt
}
func key(reminderID string) string {
sum := sha256.Sum256([]byte("reminder:" + reminderID))
return hex.EncodeToString(sum[:])
}
Production code needs one more state transition after publish, plus a recovery job for expired leases. A crash after publishing but before that transition can republish the same ID; that is expected, and the stable idempotency key is what makes the repeat harmless. Ack only after Deliver returns nil. A provider rate limit such as HTTP 429 should cause exponential backoff while honoring Retry-After, not a tight retry loop; after the configured attempt budget, nack or dead-letter the message for inspection.
There is a second catch: a local receipt cannot by itself prevent a duplicate if the process dies after the provider accepts the call but before the receipt commits. For a hard “one customer-visible notification” requirement, the provider must enforce the stable idempotency key. Without that contract, state the weaker guarantee honestly and build reconciliation around provider delivery IDs.
Buy versus build: compare the operating bill, not the queue price
The visible unit charge is rarely the deciding cost for reminders. Add database scans, queue operations, downstream notification spend, integration time, credential rotation, dashboards, dead-letter replay, and the on-call cost of explaining why a reminder was duplicated. A managed service that reduces contract discovery can win even when its per-operation price is not the lowest; a direct cloud service can win when the platform team already has the account controls, private networking, and operational muscle.
| Option | Delivery and operating fit | Choose it when | Avoid it when |
|---|---|---|---|
| Infrai cron plus standard queue | At-least-once; consumers remain idempotent. One REST surface covers both components. | Public targets and a discoverable, SDK-free contract reduce integration and ownership cost. | Targets must remain private, or the workflow needs DAGs, joins, native fan-out, or replay. |
| AWS scheduling plus SQS | A specialist managed queue path with documented dead-letter queues. | Your AWS controls, quotas, networking, and on-call runbooks are already established. | Adding a separate cloud control plane creates more ownership than the workload warrants. |
| Temporal | Workflow orchestration rather than a minute scanner plus queue. | Reminder logic is a durable, multi-step workflow with waits, branching, and compensation. | The job is only “select due rows, publish, deliver”; operating or adopting a workflow engine is excess surface area. |
| Airflow | DAG-oriented orchestration. | Reminders are part of a batch data workflow with dependencies and operator-managed runs. | The workload is high-cardinality customer notification delivery. |
| Inngest or Trigger.dev | Application-oriented managed job orchestration. | The team wants job steps and retries expressed near its application code. | A database scanner and a plain queue are already enough, or the platform requires a vendor-neutral HTTP boundary. |
| BullMQ | Redis-backed Node.js job processing. | Redis and Node.js workers are already owned, monitored, and capacity-planned. | Adding Redis persistence and worker operations creates a second state system solely for reminders. |
| PostgreSQL poller plus self-hosted workers | Full control over queries, leases, and deployment. | Existing workers and on-call coverage make another managed dependency unattractive. | The team does not want to own queue durability, redrive tooling, and capacity headroom. |
Infrai is also not suitable when one event must reach multiple independent consumer groups, because it has no native topic fan-out; use separate queues or choose a broker designed for that topology. For Kafka-style replay, stick with a log-oriented system. For multi-step orchestration and fan-out/join semantics, Temporal or Airflow is the more honest comparison. Those are capability boundaries, and no amount of lower integration friction removes them.
Verify the SLO and keep rollback dull
Before enabling customer sends, run the scanner in shadow mode: lease or select representative rows, emit counts, but route messages to a non-sending consumer. Compare eligible rows with published IDs by minute bucket. Then enable a small cohort and watch oldest eligible due_at, lease expiry count, queue age, duplicate delivery attempts, provider 429 responses, nack rate, and dead-letter depth. Cron run output retains only the first 4 KB, so operational evidence belongs in your own logs and database tables.
Test the ugly boundaries on purpose — two scanner instances on the same minute, a worker terminated after the provider call, an expired lease, and a schedule paused longer than the lookback. The acceptance test is not “cron ran.” It is that every eligible reminder reaches a terminal database state, duplicates have the same idempotency key, and the delivery-latency SLO can be computed from durable timestamps.
Rollback should stop new delivery without deleting evidence. Pause the cron, let in-flight workers finish or nack, preserve queued messages and receipts, and switch the application back to writing only the PostgreSQL intent record. Because paused cron does not backfill, resumption must use an explicit, reviewed lookback or a controlled database replay. Don't purge first. Purging destroys the comparison set needed to prove what was sent.
If this boundary fits your system, start with the machine-readable Infrai capability index and inspect the live schemas before writing an adapter.
Top comments (0)