Daily reminder fan-out: cron plans, the queue paces, the sender batches

Why one daily send job breaks at volume, and the three-stage shape that replaces it — with Infrai's measured delay ceiling, batch limits and the email-versus-SMS cost ratio.

Split the work three ways: a frequent cron tick that plans, a queue that paces, and a worker that sends in batches. On Infrai all three sit behind one key — POST /v1/cron/create for the tick, POST /v1/queue/publish_batch for the fan-out, POST /v1/email/batch/send for delivery — so the pattern doesn’t cost you three vendor accounts to assemble.

The anti-pattern is the one-shot daily job that loops over every due reminder and sends inline. It works at a thousand users and fails at a hundred thousand, always the same way: something 500s at row 40,000, the retry starts again at row 1, and forty thousand people get a second copy.

That’s a support incident, not a bug report.

Why the tick should be every five minutes, not every day

A planner that runs at */5 * * * * and claims a bounded page of due reminders has properties the daily version can’t have. A crash loses five minutes of planning, not a night. Reminders due at 09:00 in Auckland and 09:00 in Lisbon are the same query at different ticks. And the run history gives you a heartbeat every five minutes rather than one data point a day.

curl -X POST https://api.infrai.cc/v1/cron/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "reminder-planner",
    "task": "https://example.com/internal/reminders/plan",
    "cron_expr": "*/5 * * * *",
    "timezone": "UTC",
    "overlap_policy": "skip",
    "timeout_seconds": 120,
    "retry": 1,
    "payload": {"batch_limit": 2000}
  }'

overlap_policy: "skip" is the default and you want it here — a planner that overruns its five-minute window must not be started again alongside itself, or the same rows get claimed twice.

The planner: claim rows, publish, don’t send

The planner’s only job is to turn database rows into queue messages. It never talks to an email provider, which is what makes it fast enough to finish inside 120 seconds.

Here is the wire format, first, because the script is easier to read once you know what it emits:

curl -X POST https://api.infrai.cc/v1/queue/publish_batch \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "queue": "kh-cron-reminders",
    "messages": [
      {"payload": {"reminder_id": "rem_10", "tenant_id": "t_7", "channel": "email", "to": "ada@example.com"}},
      {"payload": {"reminder_id": "rem_11", "tenant_id": "t_7", "channel": "email", "to": "grace@example.com"}, "delay_seconds": 600}
    ]
  }'

Every message comes back with the moment it becomes visible, which is the field to log if you ever have to explain a late reminder:

{
  "ok": true,
  "data": {
    "items": [
      {
        "message_id": "qmsg_WJ9bI3mxSwKbLtsaf5t0O6ma",
        "queue": "kh-cron-reminders",
        "payload": {"reminder_id": "rem_10", "tenant_id": "t_7", "channel": "email", "to": "ada@example.com"},
        "status": "available",
        "delivery_count": 0,
        "available_at": "2026-07-26T05:21:36Z",
        "priority": 0
      }
    ]
  }
}

The planner below produces exactly that, in pages, straight out of a claim query.

import postgres from "postgres";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const sql = postgres(process.env.DATABASE_URL);

async function publishBatch(queue, messages) {
  const res = await fetch("https://api.infrai.cc/v1/queue/publish_batch", {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ queue, messages }),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    throw new Error(`publish_batch -> ${res.status} ${json?.error?.message ?? ""}`);
  }
  return json.data.items;
}

// Claim atomically so two overlapping planners can never take the same row.
const due = await sql`
  UPDATE reminders SET state = 'queued', queued_at = now()
  WHERE id IN (
    SELECT id FROM reminders
    WHERE state = 'pending' AND due_at <= now() + interval '1 hour'
    ORDER BY due_at LIMIT 2000 FOR UPDATE SKIP LOCKED
  )
  RETURNING id, user_id, channel, email, phone, due_at, tenant_id`;

for (let i = 0; i < due.length; i += 100) {
  const slice = due.slice(i, i + 100);
  const items = await publishBatch("kh-cron-reminders", slice.map((r) => ({
    payload: {
      reminder_id: r.id,
      tenant_id: r.tenant_id,
      channel: r.channel,
      to: r.channel === "sms" ? r.phone : r.email,
    },
    // Fire at the exact due moment rather than now; capped at 7 days.
    delay_seconds: Math.min(604800, Math.max(0, Math.floor((r.due_at - Date.now()) / 1000))),
  })));
  console.log(`queued ${items.length} reminders`);
}

await sql.end();

FOR UPDATE SKIP LOCKED plus the state transition is the claim. Do that and the overlap question stops mattering at the application layer, which is a better place to solve it than in the scheduler.

Claim first, send later.

The delay knob, and its edges

delay_seconds is per message and it’s honoured — we published with 3600 and the queue reported delayed_count: 1 while available_count stayed at 0. The ceiling is exactly 604800 seconds, seven days: 604800 succeeds, 604801 gets rejected.

Two rough edges are worth knowing before you build on it. The publish response reports status: "available" even for a delayed message, so trust GET /v1/queue/stats/{queue} rather than the create response if you’re asserting on it. And a delay above the ceiling returns a 400 whose message talks about the queue already existing — the rejection is correct, the wording isn’t, so clamp client-side as the planner above does.

There’s also a delivery_delay_seconds on the queue itself. In our testing, setting it through PATCH /v1/queue/update/{queue} had no effect on delivery, so treat per-message delay_seconds as the mechanism that actually works.

curl https://api.infrai.cc/v1/queue/stats/kh-cron-reminders \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The worker: where rate limiting actually lives

max_messages on consume is capped at 10. That cap is your rate-limit dial: one worker polling once a second is 10 messages per second, five workers is 50, and you size the fleet to whatever your sending domain’s warm-up state allows rather than to how fast the database can spit out rows.

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const QUEUE = "kh-cron-reminders";

const call = async (path, body) => {
  const res = await fetch(`https://api.infrai.cc${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`${path} -> ${res.status} ${json?.error?.message ?? ""}`);
  return json.data;
};

for (;;) {
  const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10, visibility_timeout: 120 });
  if (!items.length) {
    await new Promise((r) => setTimeout(r, 1000));
    continue;
  }

  const emails = items.filter((m) => m.payload.channel !== "sms");
  if (emails.length) {
    await call("/v1/email/batch/send", {
      messages: emails.map((m) => ({
        to: m.payload.to,
        from: "reminders@example.com",
        template_id: "tmpl_reminder_due",
        template_vars: { reminder_id: m.payload.reminder_id },
      })),
      idempotency_key: `reminders-${emails[0].message_id}`,
    });
  }

  for (const m of items) {
    const ack = await call("/v1/queue/ack", { queue: QUEUE, message_id: m.message_id });
    if (!ack.acked) console.warn("lease expired before ack", m.message_id);
  }
}

Check acked. An ack for a message whose lease has already lapsed returns HTTP 200 with acked: false — no exception, no retry — and a worker that ignores it will report success for a reminder that’s about to be delivered a second time by somebody else.

One branch, two lines, saves a duplicate-send postmortem.

Sending outside a worker loop is the same call with an explicit body:

curl -X POST https://api.infrai.cc/v1/email/batch/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"to": "ada@example.com", "from": "reminders@example.com", "subject": "Your trial ends tomorrow", "html": "<p>Two days left on the Pro trial.</p>"},
      {"to": "grace@example.com", "from": "reminders@example.com", "subject": "Your trial ends tomorrow", "html": "<p>Two days left on the Pro trial.</p>"}
    ],
    "idempotency_key": "reminders-2026-07-26-batch-041"
  }'

One idempotency key covers the whole batch, and re-sending it doesn’t re-charge the individual messages — which matters, because a worker that crashes between the send and the ack will retry.

The numbers that shape the design

StepRate, verified 2026-07-26Notes
cron.create and the whole cron namespacefree, rate-limitedDoesn’t consume trial credit
queue.publish$0.00002 per message
queue.publish_batch$0.001 per callBreak-even is exactly 50 messages per call
email.send / email.batch.send$0.000115 per emailOne idempotency key covers a whole batch
sms.send$0.007475 per messageRoughly 65x an email

That last ratio is the durable fact, not the rates themselves. It’s why a reminder system should default to email and treat SMS as an escalation the user opted into — a hundred thousand daily reminders is about $12 by email and about $750 by SMS, and no amount of batching changes the shape of that.

curl "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'] in ('queue.publish','queue.publish_batch','email.batch.send','sms.send')])"

Read that before you commit a budget to a slide. Rates on this platform have moved downward and discount campaigns run, so the live figures may be better than the table above. New accounts start with $2 in credit, which is roughly 17,000 emails.

Default to email. Earn the right to send SMS.

Limits, and when to use something else

Infrai’s scheduler doesn’t do per-recipient timezone scheduling for you — delay_seconds is the primitive and computing the offset is your planner’s job, which is why the example does the arithmetic in SQL-adjacent code rather than in a config field. There’s no fan-out step type either: the queue is flat, so a reminder that needs three ordered steps with compensation isn’t what this shape is for. The sending side has its own gates worth reading before launch day: a new sender domain warms up rather than accepting a hundred thousand messages on its first morning, GET /v1/email/domain/get/{domain} reports the current daily cap and bounce rate, and the suppression list silently drops addresses that have already bounced or unsubscribed — which is correct behaviour, but it means your reminder counts and your delivery counts will legitimately disagree, and you should reconcile them with GET /v1/email/event/list rather than assuming a bug.

Inngest and Trigger.dev both model fan-out as a first-class step with per-step retries and a replayable event log, and for a reminder pipeline that keeps growing branches you’d be better off there. BullMQ is the right answer if you already run Redis and want the worker library rather than an HTTP API. QStash covers scheduled delivery neatly if that’s the only piece you’re missing.

What you get here instead is that the tick, the queue, the email, the SMS and the per-tenant cost attribution are one account and one bill. Tag the tenant_id through the payload and “what did this customer’s reminders cost us” is a usage query rather than a reconciliation across four invoices — which, at a hundred thousand reminders a day, tends to be the question finance asks first.

References

Browse more cron developer guides