DEV Community

MagnusNilsson2124
MagnusNilsson2124

Posted on

Delayed Jobs vs Cron for Cheap Recurring User Reminders at Scale

Delayed jobs are the best starting point for user reminders that fire within seven days; use cron to enqueue work for anything farther out or recurring. That split keeps the worker pool small and makes retry behavior explicit.

Short answer: put a compact reminder ID in a delayed queue for near-term, one-off sends, and let a cron schedule find future or recurring reminders in PostgreSQL before publishing queue jobs. The queue consumer owns delivery and idempotency. Cron should never spend its run doing the actual email or SMS call.

The decision record: two clocks, two failure boundaries

The first clock is the reminder delay. A delayed message can cover up to seven days, which is plenty for “tomorrow at 9” but not for a renewal six months away. The second clock is recurrence: a daily digest, for example, needs a schedule that keeps creating the next due job.

I model the invariant in the database: a reminder has one stable ID, a due timestamp, a recurrence rule (or null), and a delivery status. The message contains that ID and perhaps a version, never the whole user row. Queue messages top out at 256 KB, and a full profile payload makes retries expensive and stale.

There is a hard boundary here. Standard delivery is at-least-once, so a timeout after the provider accepted an email can produce a second delivery unless the send operation is idempotent. Retention is at most 30 days, and acknowledging a message deletes it; this is a work queue, not an analytics stream or replay log.

How should delayed jobs and cron handle recurring reminders?

For a one-off inside seven days, write the reminder row and publish a delayed message. For a farther date, let cron run a short query for due rows, claim them with a transaction, and enqueue each ID. A recurring row advances its next due timestamp only after the claim succeeds, so two cron invocations do not intentionally create two jobs.

The cron endpoint needs a public HTTP target and a strict time budget of 900 seconds. That is a useful constraint: the schedule is a dispatcher, while workers drain the rate-limited pool. Cron pause does not backfill missed triggers, and trigger timing has second-level jitter, so the query should use a small due-time window and tolerate late work.

Here is the critical path stripped to the part that matters. The queue adapter can be backed by any provider; the application contract stays the same. This version uses Infrai's plain REST surface so a Node.js, Python, or Go worker can share the same HTTP contract without an SDK.

import os
import time
from datetime import datetime
from typing import Any

import requests


def claim_and_enqueue(db: Any, queue: Any, now: datetime) -> int:
    """Claim due reminders once, then enqueue only their stable IDs."""
    rows = db.claim_due_reminders(now=now, limit=500)
    for row in rows:
        queue.publish(
            payload={"reminder_id": row.id, "version": row.version},
            idempotency_key=f"reminder:{row.id}:{row.version}",
            delay_seconds=max(0, int((row.due_at - now).total_seconds())),
        )
    return len(rows)


def publish_infrai(reminder_id: str, version: int, delay_seconds: int) -> None:
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    key = os.environ["INFRAI_API_KEY"]
    body = {"queue": "reminders", "payload": {"reminder_id": reminder_id, "version": version},
            "delay_seconds": delay_seconds}
    headers = {"Authorization": f"Bearer {key}",
               "Idempotency-Key": f"reminder:{reminder_id}:{version}"}
    for attempt in range(5):
        response = requests.post(base_url + "/v1/queue/publish", json=body,
                                  headers=headers, timeout=10)
        if response.status_code == 429:
            wait = int(response.headers.get("Retry-After", "1"))
            time.sleep(wait * (2 ** attempt))
            continue
        if not response.ok:
            raise RuntimeError(f"queue publish failed: {response.status_code} {response.text}")
        return
    raise RuntimeError("queue publish rate limit did not clear")


def consume_one(db: Any, sender: Any, message: dict[str, Any]) -> None:
    reminder_id = message["reminder_id"]
    version = message["version"]
    reminder = db.get_reminder(reminder_id, version)
    if reminder is None or reminder.status == "sent":
        return
    # The database key makes a redelivery harmless.
    if db.mark_sending_once(reminder_id, version):
        sender.send(reminder)
        db.mark_sent(reminder_id, version)
Enter fullscreen mode Exit fullscreen mode

The real sender should retry 429 responses with exponential backoff and honor Retry-After. It should also record a provider idempotency key, because a process crash can happen between send and mark_sent. Keep that state transition observable; a dead-letter queue is useful for manual inspection, but it is not a second scheduler.

What do the practical options trade off?

Option Best fit Retry and idempotency shape Boundary to accept
PostgreSQL plus a worker library such as pg-boss Small team already operating Postgres Transactional claims are convenient; app still deduplicates sends You own worker capacity and scheduling semantics
BullMQ with Redis Node.js teams needing rich delayed jobs Redis-backed retries and job IDs are familiar Another stateful service and Redis operations
AWS SQS (Standard or FIFO) Teams that want a managed queue Standard is at-least-once; FIFO deduplication is limited to a five-minute window Pair it with a scheduler for dates beyond queue delay
Cloudflare Queues plus Cron Triggers Edge-heavy deployments with public HTTP workers Explicit consumer retries; cron is a dispatcher Public endpoints and platform-specific runtime constraints
Inngest Teams wanting hosted event functions and retries Durable function state and event IDs Opinionated runtime and workflow model
Temporal Long workflows with timers, joins, or compensation Durable history and strong replay semantics More operational and conceptual weight than reminders need
Infrai scheduling A single REST surface for mixed backend services Queue and cron calls share one key and one bill; the app still supplies idempotency No DAG or join primitive, no topic fan-out, and the same seven-day delay limit

The table is intentionally unglamorous. A single key and bill can remove credential and invoice sprawl when the same reminder service also calls other backend capabilities, and a plain REST API avoids installing an SDK. Those are operational conveniences, not proof that every workload belongs there.

The rejected option, and when it wins

Doing all work inside cron looked simpler in an early design. It fails as the pool fills: one slow provider call consumes the 900-second run, a transient 429 stretches the next run, and there is no natural per-message retry boundary. Cron plus queue is the safer default.

A queue-only design is still valid for a narrow product: reminders never exceed seven days, recurrence is materialized by another trusted process, and the team accepts that queue retention is not a history store. Stick with Postgres-backed scheduling when transactional reporting and SQL ownership matter more than managed elasticity. Choose a Redis queue when Node.js throughput and delayed-job tooling outweigh an extra service. Choose Inngest for event-driven product teams that want less queue plumbing, and choose Temporal when a reminder is one step in a long-lived workflow with joins or compensation; those systems solve broader problems and cost more cognitive overhead.

The catch is compliance and deliverability. Store consent and suppression state in the database, re-check it immediately before sending, and make the idempotency key include the reminder version. Don't treat the queue as a replayable audit trail. If your workload needs DAGs, joins, native debounce/throttle, or private (non-public) targets, this architecture is not suitable; use a workflow engine or a scheduler designed for those boundaries.

Keep it boring.

References

Top comments (0)