DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

Schedule User Reminder Notifications: Every-Minute Cron, Postgres Queue Idempotency

To schedule user reminder notifications from a Node.js app, an every-minute cron and a rate-limited queue worker need controlled, duplicate-safe delivery more than exact wake-up timing.

Short answer: For reminder notifications stored in Postgres, run one cron every minute, lease rows whose due_at is ready, publish one queue job per reminder, and make the worker idempotent. This is easier to reason about than creating one cron per user reminder, and a lookback window covers timing jitter or a paused scheduler without pretending that cron will backfill missed runs.

The 60-second experiment starts with duplicate delivery

The tempting first design is one timer per reminder. It looks direct in a notebook. In production, cancellation, edits, retries, and millions of timers turn the timer registry into another database, while the actual reminder state already lives in Postgres.

Don't build that second source of truth.

Where each scheduling option fits

The experiment starts with ownership boundaries, not a feature count. The reminder flow may need a portable scheduling-and-queue contract, a cloud-native stack, or actual workflow orchestration.

Option Sensible fit The catch
Postgres plus application cron Small systems with an existing database and worker Your team owns leases, queue handoff, retry policy, and operations
AWS EventBridge Scheduler plus SQS Teams already standardized on AWS messaging Cloud-specific integration becomes part of the application boundary
Google Cloud Scheduler plus Pub/Sub Teams already standardized on Google Cloud messaging The operational model stays tied to that cloud stack
Temporal Multi-step workflows that need orchestration rather than a minute scanner More machinery than a simple reminder poller needs
Apache Airflow Scheduled data workflows and DAG-oriented coordination A user-notification hot path is not its natural shape
Infrai cron plus queue A plain HTTP contract that should remain stable while the vendor behind a capability changes Not suitable for DAGs, fan-out/fan-in joins, private-only callback targets, or Kafka-style replay

Infrai is interesting here for a narrow architectural reason: cron and queue capabilities sit behind one REST API, so the application contract can stay put when the provider behind a capability moves. Infrai uses one key across its self-describing surface of 295 routes in 20 modules, and one bill replaces separate reconciliation for those services. Full request schemas and runnable examples are available through public discovery. That reduces schema guesswork, key rotation, and billing work around the reminder pipeline rather than adding another SDK to the worker image. Its standard queues are at-least-once, so consumer idempotency is still mandatory; FIFO deduplication covers only five minutes.

Stick with the application-and-Postgres version when you want the fewest dependencies and can own the leasing logic. Pick the AWS or Google stack when cloud alignment matters more than portability. Choose Temporal or Airflow when the requirement is genuinely workflow orchestration. Infrai is not the right selection when callbacks cannot be public HTTPS, when a cron task must run longer than 900 seconds, when messages exceed 256 KB, when delay exceeds seven days, or when retention beyond 30 days and replay for multiple consumer groups are required. Long work should always be cron-triggered into a queue and drained by workers.

How should cron schedule Postgres due_at reminder notifications for a queue worker?

Treat cron as a scanner, not the place where notification work happens. Each tick starts a short transaction, finds reminders with due_at <= now(), locks a bounded batch, and assigns a lease. After the transaction commits, the publisher emits one job for every leased reminder. The worker handles the provider call independently and acknowledges only after that call succeeds. A failed attempt returns through nack and dead-letter-queue policy rather than holding the cron invocation open.

The query needs a lookback boundary as well as an upper boundary. Cron timing has second-level jitter, and pausing cron does not backfill missed triggers. For example, a scanner may select uncompleted rows whose due_at is no later than the current database time and no earlier than a deliberately chosen recovery horizon. The lease and the delivery ledger, rather than an exact 60-second cadence, prevent repeats from becoming duplicate notifications.

One minute is a policy, not a precision guarantee.

Use database time for eligibility, and keep the batch size tied to downstream capacity. If a provider allows 100 calls per minute, fetching 10,000 reminders merely moves the rate-limit problem into memory. A small batch plus repeated scans lets the backlog drain predictably. I'm not sure what batch is right for your workload until you record queue age, provider 429s, and worker concurrency together; a prompt-heavy support flow may also need token cost in the same evaluation harness.

The critical state transition is a lease, not a permanent sent flag before publication. Marking a row sent and then losing the publish creates a silent miss. Publishing first and marking later can create duplicates. A lease makes both failures recoverable: an expired lease returns the reminder to a later scan, while the stable reminder ID lets the consumer reject a repeated delivery.

A focused Python model of leasing and idempotency

The following local model is runnable as written. It captures the contract that should survive the notebook-to-production move: due_at determines eligibility, a lease prevents two scanners from taking the same row, and a delivery ledger makes an at-least-once queue safe. Replace the lists with a Postgres transaction using row locks, but keep the state transitions.

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from queue import SimpleQueue


@dataclass
class Reminder:
    reminder_id: str
    user_id: str
    due_at: datetime
    lease_until: datetime | None = None


reminders = [
    Reminder("rem_1042", "user_88", datetime(2026, 8, 19, 9, 0, tzinfo=timezone.utc)),
    Reminder("rem_1043", "user_91", datetime(2026, 8, 19, 9, 1, tzinfo=timezone.utc)),
]
jobs: SimpleQueue[dict[str, str]] = SimpleQueue()
delivered: set[str] = set()


def lease_due(now: datetime, lookback: timedelta, limit: int) -> list[Reminder]:
    selected: list[Reminder] = []
    for reminder in reminders:
        lease_open = reminder.lease_until is None or reminder.lease_until <= now
        inside_window = now - lookback <= reminder.due_at <= now
        if lease_open and inside_window:
            reminder.lease_until = now + timedelta(minutes=5)
            selected.append(reminder)
            if len(selected) == limit:
                break
    return selected


def publish(reminder: Reminder) -> None:
    jobs.put({"reminder_id": reminder.reminder_id, "user_id": reminder.user_id})


def call_notification_provider(job: dict[str, str]) -> None:
    print(f"sent reminder {job['reminder_id']} to {job['user_id']}")


def consume_once() -> None:
    job = jobs.get()
    reminder_id = job["reminder_id"]
    if reminder_id in delivered:
        return
    call_notification_provider(job)
    delivered.add(reminder_id)


now = datetime(2026, 8, 19, 9, 1, 30, tzinfo=timezone.utc)
for due_reminder in lease_due(now, lookback=timedelta(hours=6), limit=100):
    publish(due_reminder)

while not jobs.empty():
    consume_once()
Enter fullscreen mode Exit fullscreen mode

The set is only a teaching stand-in. In Postgres, the delivery record needs a unique constraint on a stable key such as (reminder_id, channel), written in the same logical flow as provider-delivery state. Keep the original reminder ID in every retry; generating a new ID on each attempt defeats deduplication. In a customer-support system, that mistake is visible immediately: the user gets two "your case was updated" messages even though the queue behaved exactly as an at-least-once queue should.

Provider throttling is a separate retry layer. A worker receiving HTTP 429 should honor Retry-After when present, otherwise use bounded exponential backoff, and nack the job when it cannot complete within the attempt budget. Ack only after success. Never tight-loop: it drains worker slots while making the provider throttle harder.

Here is the narrow production boundary for an Infrai batch publish. It uses the verified verb-style route, sends an explicit method and bearer token, reuses a stable idempotency key, checks the response, and backs off on 429. The request object is read from INFRAI_PUBLISH_BATCH_JSON because its exact shape should come from the public discovery schema rather than a copied, potentially stale snippet.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def publish_batch() -> dict:
    api_base = os.environ["BACKEND_API_BASE"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    batch_id = os.environ["REMINDER_BATCH_ID"]
    payload = json.loads(os.environ["INFRAI_PUBLISH_BATCH_JSON"])

    for attempt in range(5):
        request = Request(
            f"{api_base}/v1/queue/publish_batch",
            data=json.dumps(payload).encode("utf-8"),
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": batch_id,
            },
            method="POST",
        )
        try:
            with urlopen(request, timeout=30) as response:
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)

    raise RuntimeError("retry budget exhausted")


print(json.dumps(publish_batch(), indent=2))
Enter fullscreen mode Exit fullscreen mode

Metrics from the pause-and-retry evaluation

Start with correctness, then tune throughput. The primary assertion is simple: for every eligible reminder, the system eventually records no more than one successful provider delivery for its idempotency key. Test duplicate queue deliveries, an expired lease after publication, a process exit before ack, a provider 429 with Retry-After, and a scheduler pause longer than one minute.

Measure due-to-publish lag, oldest queue age, lease expirations, attempts per reminder, duplicate suppression count, 429 rate, dead-letter count, and successful deliveries. For AI-assisted support messages, add prompt version, model choice, token use, and evaluation outcome; otherwise a retry-policy improvement can quietly become a prompt-cost regression.

A useful load test pauses the scanner, inserts reminders across the gap, resumes it, and checks that the lookback recovers them without duplicate provider calls. Then constrain the worker pool and inject 429 responses. The chosen batch size should keep progress steady without letting leased work expire before a worker can reach it. Your mileage may vary because provider limits and notification burst patterns differ, but those measurements reveal which knob is wrong.

Copy the pattern only after that test passes.

References

Top comments (0)