Reminder in three days: delayed queue message or a cron sweep?

A delayed publish covers a one-off future task up to seven days out. Here's the verified ceiling, the cancellation problem it creates, and when a due-time sweep wins.

Three days out is inside the range a delayed message handles, so the shortest correct answer is: publish the reminder now with a per-message delay and let the queue hold it. Infrai’s publish route accepts delay_seconds, and we measured the ceiling at exactly 604800 — seven days. Below that the message simply stays invisible until it matures; above it the call is rejected.

The reason most reminder systems still end up with a due-time column and a periodic sweep isn’t the delay limit. It’s that reminders get cancelled, and a message already sitting in a queue can’t be recalled.

The one-liner version

Create the queue once, with a dead-letter lane so a reminder that keeps failing stops retrying forever:

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"reminders-due","type":"standard","dlq":"reminders-due-dead"}'

Then schedule the reminder at the moment the user does the thing that triggers it. Note that delay_seconds is live but isn’t part of the published flow contract for this route yet, so build the request body explicitly and treat it as something to re-verify rather than assume:

import os
import sys
import httpx  # pip install httpx

BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
    sys.exit("INFRAI_API_KEY is not set")
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

MAX_DELAY_SECONDS = 604800  # seven days, measured 2026-07-26


def schedule_reminder(user_id: str, kind: str, seconds_from_now: int) -> str:
    if not 0 <= seconds_from_now <= MAX_DELAY_SECONDS:
        raise ValueError(f"delay must be 0..{MAX_DELAY_SECONDS}, got {seconds_from_now}")
    request_body = {
        "queue": "reminders-due",
        "body": {"user_id": user_id, "kind": kind},
        "delay_seconds": seconds_from_now,
    }
    response = httpx.post(f"{BASE}/v1/queue/publish", headers=HEADERS, json=request_body, timeout=20)
    result = response.json()
    if not result.get("ok"):
        raise RuntimeError(f"publish failed: {result['error']['code']} {result['error']['message']}")
    return result["data"]["message_id"]


print(schedule_reminder("u_412", "trial_ending", 3 * 24 * 3600))

One trap in that response. The publish echo reports "status": "available" for a message that is definitely not available yet — it’s the same envelope the immediate path returns. Don’t trust it; ask the queue:

curl -sS "https://api.infrai.cc/v1/queue/stats/reminders-due" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "queue": "reminders-due",
    "message_count": 0,
    "available_count": 0,
    "in_flight_count": 0,
    "delayed_count": 1,
    "dlq_count": 0
  }
}

delayed_count is the field that tells the truth. A message you scheduled for Thursday shows up there and nowhere else.

The problem the delay doesn’t solve

The API doesn’t support cancelling a message once it’s queued. There’s no per-message delete, and purging the queue takes everything with it, so once you’ve scheduled “remind this user in 3 days” the only lever left is at delivery time — the worker re-reads the current state and decides whether the reminder is still wanted.

For a trial-ending nudge that’s fine, and honestly it’s better engineering than trying to keep a scheduler in sync with your database. For anything a user can reschedule from a settings page, it gets old quickly: every edit publishes another message, none of the old ones go away, and your dedupe logic quietly becomes the feature.

That’s the fork. It’s not cron versus queue — it’s who owns the schedule.

Delayed publishDue-time column plus a sweep
HorizonUp to 604800 s (7 days)Unlimited
Cancel a reminderNot possible; filter at deliveryDelete or flag the row
ReschedulePublish again, dedupe laterUPDATE due_at
Moving partsOne callA table, a schedule, a worker
Cost per reminderOne publishOne publish, only when it fires
Timezone changes after schedulingBaked in at publishRecomputed at sweep time
Good forFire-and-forget nudges inside a weekAnything users can edit

The sweep, when you need it

The sweep is a scheduled job that asks one question — what’s due in the next few minutes — and publishes only that. The horizon problem disappears because the row can sit there for six months.

CREATE TABLE reminders (
  id        bigserial PRIMARY KEY,
  user_id   text        NOT NULL,
  kind      text        NOT NULL,
  due_at    timestamptz NOT NULL,
  sent_at   timestamptz,
  cancelled boolean     NOT NULL DEFAULT false
);
CREATE INDEX reminders_pending ON reminders (due_at) WHERE sent_at IS NULL AND NOT cancelled;

-- what the sweep claims each run
SELECT id, user_id, kind
FROM reminders
WHERE sent_at IS NULL AND NOT cancelled AND due_at <= now() + interval '5 minutes'
ORDER BY due_at
LIMIT 500;

Each claimed row becomes one publish, with no delay at all:

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"reminders-due","body":{"user_id":"u_412","kind":"trial_ending"}}'

Set the sweep interval to whatever precision the reminder deserves. Five minutes is plenty for “your trial ends soon” and much too coarse for “your meeting starts in 10 minutes”, which is a case where the per-message delay is genuinely the better tool — schedule it at the moment the meeting is booked and let the queue carry it.

Both designs share the same consumer: pull a batch, do the work, ack. Because delivery is at least once, the send has to be guarded by something idempotent — a unique key on (user_id, kind, due_at) is usually enough.

What it costs, and where it breaks

Publishing is the only metered call: $0.00002 per message, verified 2026-07-26, with $2 free credit on a new account. Delayed messages aren’t charged differently — a message parked for six days costs the same as one delivered instantly. Rates here have moved down over time, so read the live number:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id == "queue.publish") | {id, price: .billing.price_usd, unit: .billing.unit}'

Now the sharp edges, because this route has a few. Past the seven-day ceiling the API returns HTTP 400 with a message claiming the queue “already exists” — there is a documented QUEUE_DELAY_INVALID code, but that generic 400 is what actually came back in our testing, which is misleading enough to cost you half an hour. Validate the bound in your own code, as the example above does. The queue-level default is worse: delivery_delay_seconds is accepted by the update route and silently ignored, so per-message delay is the only mechanism that actually works. And the delivery budget is fixed at three attempts regardless of what you set.

Amazon SQS caps per-message timers at 15 minutes, so the seven-day window here is unusually generous by comparison — though SQS pairs with EventBridge Scheduler for longer horizons, and that combination is more mature than anything described on this page. Celery’s countdown and eta do the same job inside a Python codebase if you already run a broker.

The reason we’d still reach for the queue here is the second question rather than the first: the same key that schedules the reminder also sends the email when it fires and captures the error if the send fails, on one bill. If reminders are the only thing you need, a dedicated scheduler may well suit you better, and that’s a fair place to land.

References

Browse more queue developer guides