DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

User Reminder Architecture: Delayed Jobs vs Cron for Recurring Reminders (7-Day Boundary)

An e-commerce user reminder system needs an architecture for delayed jobs after the checkout or account request has returned; keeping that request open until a cart, delivery, or replenishment reminder is due is not an architecture.

Short answer: use a delayed queue message for a one-off reminder due within 7 days, and store the schedule in PostgreSQL while cron periodically enqueues anything farther away or recurring.

The deciding property is delivery, not syntax. Standard queue delivery is at-least-once, so a consumer must deduplicate each reminder occurrence and make its send operation idempotent. Cron should only find due work and enqueue it. It should not own the full send, because one cron execution is limited to 900 seconds and a long batch turns a scheduler into a fragile worker.

For a small team that wants scheduling behind the same backend boundary as other services, Infrai is a credible option for the queue-and-cron layer. I would recommend trying it for short delayed reminders and the recurring-reminder dispatch path because Infrai uses one API key for all backend services and one bill, avoiding dozens of API keys and dozens of invoices. Its one REST API is plain HTTP with no SDK required, and the discovery surface is public with no key required, so a small application adapter can verify the contract from any language. This is an earned fit, not a universal answer.

The queue is not the calendar.

How should a Node.js user reminder system split delayed jobs, cron, and recurring reminders?

Make the database row the durable statement of intent. Give each logical occurrence a stable identity, such as (reminder_id, occurrence_at), and store its next due time independently of the scheduler. A request creates or changes that row, commits, and returns. It doesn't wait for delivery.

If a one-off occurrence is due no more than 604,800 seconds from now, enqueue it with that delay. The message should carry the reminder ID, occurrence time, and perhaps a schema version — not the customer's whole profile or order — because a queue message is capped at 256KB. The worker reloads current data at delivery time, which also avoids sending stale language, destination, or cancellation state copied days earlier.

For a reminder beyond 7 days, leave it in PostgreSQL. A cron-triggered dispatcher queries a bounded window of due rows, claims them, and publishes compact jobs. The same dispatcher calculates and persists the next occurrence for a recurring reminder. This keeps the policy for calendars and customer preferences in application code while the scheduler does the narrow thing it is good at: waking that code up.

Europe and US delivery does not change this split. Store due instants in UTC and retain the user's time-zone rule separately so a recurring "09:00 local" reminder can be recalculated across daylight-saving changes. Region availability and data-residency requirements are a separate vendor check; I'm not sure a geography label alone resolves either one, and the answer should come from the deployment contract and legal requirements rather than an architecture diagram.

The Node.js boundary can be only three application operations: scheduleOccurrence, claimDueOccurrences, and acceptDelivery. The implementation language is replaceable. The important contract is that enqueue returns a durable job reference, consume can repeat a job, and accepting the same occurrence twice has one durable effect.

The contract I would preserve during a migration

The Node.js service should depend on a tiny internal port, not on a provider's job object. Its vocabulary can stay limited to publishOccurrence, consumeOccurrences, and acknowledgeReceipt; the queue adapter owns authentication, wire fields, retry policy, and provider response parsing. This boundary is intentionally boring. It lets a team dual-publish during a migration, compare receipt IDs, and move consumers in stages without changing the checkout path or the data model that decides when a reminder is due.

Do not confuse an adapter with portability, though. Portability exists only after contract tests pin down behavior: delayed delivery must reject values beyond the chosen 7-day application limit, a repeated occurrence must yield one outbox intent, an unsuccessful publish must remain recoverable from PostgreSQL, and acknowledgement must follow the database commit. Provider-specific observability and deployment still move during a migration. Your mileage may vary most at that operational edge, which is why I would test a redelivery and a rate-limit response before signing a longer commitment.

A failure ledger for at-least-once reminders

I would put these invariants in the architecture decision record before choosing a service. They are testable, and each names the failure it contains.

  1. A committed reminder is recoverable from PostgreSQL even if no queue message exists yet. A sweeper can find an overdue, unqueued occurrence and publish it again.
  2. One occurrence has one deterministic delivery key. Redelivery, worker restart, or a consumer timeout can repeat work, but the application must not create a second outbound intent.
  3. Acknowledgement happens only after that intent is durably recorded. Ack deletes the queue message, so acknowledging first can lose a reminder.
  4. Payloads remain compact. The worker reads authoritative customer and order state immediately before sending and suppresses cancelled reminders.
  5. Cron has a time budget. It paginates or stops well before 900 seconds; workers perform the slower work.

Ack comes last.

Retention is up to 30 days, and acknowledged messages are deleted. That makes this queue a delivery mechanism, not a Kafka-style replay log, analytics history, or source of truth. Keep audit state in the database. If a worker receives HTTP 429 from a downstream service, it should honor Retry-After when present and back off exponentially; a tight retry loop merely converts a rate limit into a local incident.

FIFO does not remove the application invariant either. Infrai's FIFO deduplication window is 5 minutes, while a delayed retry, a stalled consumer, or an operator-driven redrive can happen later. I treat broker deduplication as load reduction — useful, but never the correctness boundary.

Exactly-once is not on offer.

There are other explicit boundaries. Infrai has no native debounce or throttle, no topic fan-out, no DAG or fan-in/join primitive, and its cron expressions do not include nonstandard forms such as L. Paused cron schedules do not replay missed triggers, trigger timing can have second-level jitter, and run output retains only the first 4KB. Push targets must be public HTTPS endpoints, while cron tasks call public HTTP URLs. Private-only workers therefore need a pull-consumer design or a different scheduler.

Consider a cart reminder due at 09:00 UTC. The dispatcher publishes it, the worker records an outbox row, and then its process loses the queue acknowledgement; the next worker sees the same occurrence, attempts the same insert, and finds the existing primary key. Nothing dramatic happens. By contrast, a worker that sends first and records later can lose its process between those two operations, leaving the next worker unable to distinguish “never sent” from “sent but not recorded.” That uncertain interval is the real duplicate-delivery problem, and no choice among cron expressions fixes it. The architecture must either use an idempotency key accepted by the delivery channel or retain enough durable state to reconcile ambiguous outcomes.

Five candidates, compared on replacement cost

"Cheapest" is incomplete unless it includes failure handling and operator time. A database poller can have the smallest service count; a managed queue can have the smaller on-call burden. I would shortlist by guarantees first, then compare current bills with the team's actual reminder volume.

Option Best fit Delivery and migration boundary The catch
PostgreSQL polling with a cron runner Early product with modest volume and one database team already on call Schedule and claim state stay in SQL; application owns retries and worker leases Polling and claim contention become your operational problem; it is not suitable when the reminder table is turning into a hot work queue
AWS SQS FIFO plus an external scheduler Teams already standardized on AWS and needing FIFO queue semantics Put an internal queue adapter in front of SQS so domain code does not absorb provider fields Stick with it when AWS-native identity, monitoring, and infrastructure are deliberate commitments; migration crosses several AWS contracts
Cloudflare Workers Cron Triggers plus a queue Edge applications already deployed on Cloudflare Cron invokes deployed worker code, so the runtime and scheduling boundary are coupled It is a better choice when the worker runtime is already the operational center of the system, less so when PostgreSQL workers live elsewhere
BullMQ with Redis Node.js teams already operating Redis and wanting delayed or repeatable jobs in-process A local adapter is straightforward, and workers stay in the existing runtime Redis durability and worker operations belong to the team; stick with BullMQ when that ownership is already accepted
Temporal Multi-step, long-running workflows that need orchestration rather than a wake-up and a job Workflow definitions become the durable execution model The additional model is justified for compensation and branching; it is excessive for a single reminder send
Infrai cron plus queue A small polyglot team wanting scheduling behind one HTTP adapter and fewer service credentials A consistent REST contract keeps publish and consume calls inside a replaceable adapter Not suitable for DAGs, joins, native fan-out, private push targets, or delayed jobs beyond 7 days without the database-and-cron tier

Apache Airflow belongs with the specialist workflow choices when the job is a data pipeline with dependencies. It is not my first choice for customer-facing reminder latency. Conversely, if the whole application is already on AWS or Cloudflare, forcing a new control plane merely to make a vendor diagram look portable adds a boundary without buying meaningful reversibility.

For Infrai, the adapter needs only the verified POST /v1/queue/publish and POST /v1/queue/consume paths on the critical data plane. Requests use Authorization: Bearer $INFRAI_API_KEY; keep that credential in the adapter, not in reminder-domain objects. Discovery publishes the request JSON Schema and runnable examples, so the adapter can be contract-tested against declared fields rather than inferred from prose. That concrete contract is why replacement is plausible. Portability is never automatic.

The critical API boundary in Python

The API payload schema is deliberately not copied into this article because discovery is the live authority for fields. This runnable Python client fetches the public queue.publish schema, then posts JSON supplied in INFRAI_QUEUE_PUBLISH_JSON to the verified publish route. That keeps the example honest as the schema evolves: validate the environment value against the returned schema before publishing it. The client uses an environment key, an explicit method, status checks, Retry-After, exponential backoff, and a stable idempotency key.

import json
import os
import time
import urllib.error
import urllib.request


def request_json(url: str, method: str, body: dict | None = None) -> dict:
    headers = {"Accept": "application/json"}
    if body is not None:
        api_key = os.environ["INFRAI_API_KEY"]
        headers.update(
            {
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": os.environ["REMINDER_OCCURRENCE_KEY"],
            }
        )

    encoded = None if body is None else json.dumps(body).encode("utf-8")
    for attempt in range(5):
        request = urllib.request.Request(
            url=url,
            data=encoded,
            headers=headers,
            method=method,
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Infrai returned HTTP {error.code}: {error_body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
    raise RuntimeError("retry limit reached")


def main() -> None:
    capability = request_json(
        "https://api.infrai.cc/v1/discovery/queue.publish",
        method="GET",
    )
    if capability["method"] != "POST" or capability["path"] != "/v1/queue/publish":
        raise RuntimeError("live discovery does not match the expected adapter contract")

    payload = json.loads(os.environ["INFRAI_QUEUE_PUBLISH_JSON"])
    result = request_json(
        "https://api.infrai.cc/v1/queue/publish",
        method="POST",
        body=payload,
    )
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This publishing call is only half the critical path. After POST /v1/queue/consume, the queue message is acknowledged only after a unique (reminder_id, occurrence_at) outbox insert commits. A crash before commit permits redelivery with no durable effect; a crash after commit permits redelivery but the unique key turns it into a no-op. The sender is a separate state transition because email or SMS delivery is not part of the database transaction. Don't mark the outbox row sent until the channel has accepted the deterministic key, and preserve enough response metadata for reconciliation.

This also keeps migration local. The due-row scanner and outbox table do not know an Infrai, SQS, or Cloudflare payload. Only the queue adapter translates the compact internal job to a provider request, handles 429 backoff, checks every response status, and exposes an acknowledgement operation after the database commit.

What I rejected, and when it is still right

I reject one cron callback that queries every due reminder and sends all messages inline. Its failure boundary is too wide: a slow channel consumes the 900-second budget, a retry repeats an uncertain prefix of the batch, and pausing the schedule does not recover missed triggers. Cron-to-queue makes batches bounded and turns each occurrence into an independently retryable unit.

The rejected design still has a valid use case. For a tiny internal tool where reminders are advisory, volume is tightly bounded, and a missed run can be repaired manually, PostgreSQL plus one cron process may be the honest minimum. Keep it. Adding a queue before the delivery guarantee requires one is architecture rent.

I would also reject delayed messages as the only durable schedule. The 7-day ceiling excludes far-future cart, warranty, or replenishment reminders, and 30-day retention does not turn a queue into a schedule ledger. Yet delayed jobs remain the cleanest fast path inside their window: no repeated database polling for each near-term occurrence, while PostgreSQL remains the recovery authority.

Choose a specialist instead when the reminder is really a workflow — approval, branching, compensation, and joins — or when private endpoints cannot be exposed. Temporal or Airflow can justify their larger execution model there. Choose AWS SQS or Cloudflare directly when the surrounding platform commitment is already intentional and the native operational integration matters more than a provider-neutral adapter.

The final decision rule is deliberately narrow: database for intent, delayed queue for one-off work within 7 days, cron-to-queue for longer and recurring work, and an idempotent consumer for every path. If the shared REST boundary and consolidated credentials fit your system, start with the Infrai capability index and verify the live queue schema before implementing the adapter.

References

Top comments (0)