Daily report emails: when cron alone is enough, and when it isn't

A decision guide with working code for scheduled report emails on Infrai — run the send inline from a cron job, or have cron enqueue one message per recipient.

The honest answer to “cron or queue for a daily report email” depends on one number: how long the whole run takes. Under a couple of minutes and a single Infrai cron job that generates and sends inline is the right amount of machinery — anything else is architecture you’ll maintain for no reason. Past that, the run outlives its own execution window, and cron’s job becomes enqueueing rather than doing.

The threshold isn’t a matter of taste. Scheduled tasks here carry a timeout_seconds that defaults to 300, and a run that exceeds it is cut off mid-way.

Start with the one-job version

A scheduled job is created with POST /v1/cron/create. You give it a name, a cron expression, a timezone, task_type: "http_url" and the URL it should call — check the field name for that URL against the API reference before you copy anything, because the request spelling and the spelling that comes back in a cron.list response are not the same word. That mismatch is the single most common reason a first job 400s.

Everything else has a sensible default. Retries are 3, the timeout is 300 seconds, and overlap_policy is skip, meaning a run that is still going when the next tick arrives doesn’t get a second copy of itself. For a nightly report that’s exactly what you want.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/cron/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "job_id": "cron_xVITl4w72oBpNychyX3pXGKy",
        "name": "nightly-digest",
        "cron_expr": "17 3 * * *",
        "task_type": "http_url",
        "task_url": "https://ops.example.com/hooks/digest",
        "timezone": "UTC",
        "retry": 3,
        "timeout_seconds": 300,
        "overlap_policy": "skip",
        "enabled": true,
        "status": "active",
        "last_run_status": "failed"
      }
    ],
    "next_cursor": null
  }
}

Your endpoint queries the reporting tables, renders the HTML and sends. Fifty recipients at 200 ms per send is ten seconds — comfortably inside the window, no queue required, and the run history under GET /v1/cron/runs/list/{id} tells you whether last night worked.

That’s the whole design, and for a lot of SaaS products it never needs to grow.

Where inline stops working

Three thousand recipients at the same 200 ms is ten minutes. The job gets killed at five, half your customers have their report and the other half don’t, and the retry starts again from the top — sending a duplicate to everyone who already got one.

At that point the cron job should hand out work instead of doing it.

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

The endpoint the schedule calls now does one cheap thing: read the recipient list, publish a message per recipient, return 200 well inside the timeout. Batches of a few hundred keep the round trips down.

import express from "express";
import process from "node:process";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY missing");

const app = express();
app.use(express.json());

async function enqueue(recipients, runId) {
  const messages = recipients.map((r) => ({
    payload: { tenant_id: r.tenant_id, email: r.email, run_id: runId },
  }));
  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: "report-digest", messages }),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(`publish_batch: ${out.error.code} ${out.error.message}`);
  return out.data.items.length;
}

app.post("/hooks/digest", async (req, res) => {
  const runId = new Date().toISOString().slice(0, 10);
  try {
    const recipients = await loadRecipients();
    let queued = 0;
    for (let i = 0; i < recipients.length; i += 200) {
      queued += await enqueue(recipients.slice(i, i + 200), runId);
    }
    res.json({ ok: true, queued, run_id: runId });
  } catch (err) {
    console.error("digest fan-out failed:", err.message);
    res.status(500).json({ ok: false, error: err.message });
  }
});

async function loadRecipients() {
  // Replace with your own query; one row per email you intend to send.
  return [{ tenant_id: "t_1", email: "ops@example.com" }];
}

app.listen(3000, () => console.log("digest hook listening on :3000"));

Workers then drain it at whatever rate your email provider tolerates, and each message carries run_id so a redelivered message can be recognised as one you’ve already sent.

import process from "node:process";

const KEY = process.env.INFRAI_API_KEY;
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

const consumed = await fetch("https://api.infrai.cc/v1/queue/consume", {
  method: "POST",
  headers,
  body: JSON.stringify({ queue: "report-digest", max_messages: 10 }),
});
const { ok, data, error } = await consumed.json();
if (!ok) throw new Error(`consume: ${error.code}`);

for (const item of data.items) {
  const sent = await alreadySent(item.payload.run_id, item.payload.tenant_id);
  if (!sent) await sendDigest(item.payload);
  const acked = await fetch("https://api.infrai.cc/v1/queue/ack", {
    method: "POST",
    headers,
    body: JSON.stringify({ queue: "report-digest", receipt_handle: item.message_id }),
  });
  const result = await acked.json();
  if (!result.data.acked) console.warn(`ack ignored: ${item.message_id}`);
}

async function alreadySent(runId, tenantId) {
  const res = await fetch(`https://ops.internal.example.com/digests/${runId}/${tenantId}`);
  return res.status === 200;
}

async function sendDigest(payload) {
  const res = await fetch("https://ops.internal.example.com/digests", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`digest send failed: ${res.status}`);
}

max_messages above 10 is refused with a 400, so a worker pulls ten at a time and you scale by running more of them.

Choosing between the two

Cron onlyCron enqueues, workers send
Moving partsOne scheduled jobA job, a queue, N workers
Safe run lengthUnder the 300 s timeoutUnbounded
One recipient failsThe whole run is marked failedThat one message retries; the rest are unaffected
Retry blast radiusEverybody, againOnly the failed message
Progress visibilityRun history, after the factGET /v1/queue/stats/report-digest while it runs
Duplicate riskHigh on retry unless you dedupeLow, and scoped to one recipient

Mid-run visibility is the part people underrate:

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

What it costs, and the honest caveats

Scheduling is free — create, list, pause, trigger and run history are all unmetered, rate-limited calls. On the queue side only publishing is billed, at $0.00002 per message, verified 2026-07-26, so a 3,000-recipient nightly digest costs about $1.80 a month. New accounts get $2 of credit to start. These rates move down rather than up, so read the current one:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.data.capabilities[] | select(.id | startswith("cron.") or startswith("queue.")) | {id, billing}'

The trade-off you’re accepting: this scheduler calls a public HTTPS URL, so if your report generator only listens on a private network you’ll need a small public endpoint in front of it. Minute-level precision is the floor, there’s no sub-minute cadence, and a queue nack retries immediately with no backoff curve. If you need per-job backoff strategies and a dashboard, BullMQ is the better fit and node-cron is fine when a single always-on Node process is all you have. QStash is a reasonable alternative if scheduled HTTP delivery is genuinely the only thing you need.

What you get by staying here is the second half of the job on the same key — the queue, the email send, the storage for the rendered PDF and the error record when a send fails all sit on one account with one bill.

References

Browse more queue developer guides