Cron or a job queue? Picking one for file, email and webhook work

A decision rule for small Node backends: what belongs on a schedule, what belongs in a queue, and how a 7-day publish delay removes half the cron jobs you were about to write.

Cron decides when something starts. A queue decides what gets done, how many attempts it gets, and where the one job that keeps failing ends up. Thumbnailing an upload, sending a welcome email, replaying a webhook to a partner who was down for an hour — each of those fails on its own, so each belongs in a queue. On Infrai the queue sits behind the same key as the rest of the platform; publishing is billed per call and consuming, acking and nacking are free.

Cron still has a job. It just isn’t this one.

The question that settles it

Can this piece of work fail by itself? A nightly digest that regenerates from scratch tomorrow can be re-run wholesale, and that’s exactly what a schedule gives you. An email to one customer can’t. If the send fails at 03:07 and you re-run the 03:00 script at 04:00, everyone who already got their mail gets a second copy — so now you’re writing a “did I already send this” table, which is a queue with extra steps and no dead-letter queue.

WorkWhat triggers itWhere it belongsReason
Resize or scan an uploaded filethe upload requestqueueOne bad file must not stall the other 400
Welcome email, receipt, password reseta user eventqueueRetry per recipient; duplicates are visible to a human
Webhook sync to a partner APIeither side’s eventqueue, published with a delayPartners come back in minutes, not at 3am
Nightly digest or invoice runthe clockcron that enqueues, queue that worksThe clock picks the moment; per-user failures still need their own retry
VACUUM, cache warm, metric rollupthe clockcron aloneSingle statement, nothing to retry individually

The bottom row is the one people forget exists. Not everything needs a queue, and a two-line crontab entry that runs one SQL statement is cheaper to operate than any managed service.

Set up the queue and the failure lane

Two things matter at creation: the type, and where poison messages go.

{
  "name": "media-jobs",
  "type": "standard",
  "dead_letter_queue": "media-jobs-dlq",
  "max_retries": 3,
  "visibility_timeout_default": 300
}
export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  --data @queue.json

The response tells you the defaults you didn’t set, which is worth reading once:

{
  "ok": true,
  "data": {
    "name": "media-jobs",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "max_receive_count": 3,
    "dlq_name": "media-jobs-dlq"
  }
}

Messages live 14 days, cap out at 256 KB, and a message that has been delivered three times without an ack lands in media-jobs-dlq. Keep payloads small — an ID plus enough context to redo the work, never the file itself. A 30 MB video in a message body isn’t a design choice, it’s a QUEUE_MESSAGE_TOO_LARGE waiting to happen.

Enqueue from the request handler

The web request should do one thing: hand the work off and return.

import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function call(path, payload) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    throw new Error(`${path} → ${res.status} ${json?.error?.code ?? ""} ${json?.error?.message ?? ""}`);
  }
  return json.data;
}

export async function onUploadFinished(assetId, ownerId) {
  const message = {
    queue: "media-jobs",
    payload: { asset_id: assetId, owner_id: ownerId, op: "thumbnail" },
  };
  const { message_id } = await call("/v1/queue/publish", message);
  return message_id;
}

The field is payload in the current reference — older examples use body, which the API still accepts while warning you to switch. Your upload handler now returns in the time it takes to make one HTTP call, and a thumbnail worker crashing at 2am is no longer a 500 on the user’s upload.

The worker

import process from "node:process";

const API = "https://api.infrai.cc";
const headers = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};

async function post(path, payload) {
  const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const json = await res.json();
  if (json.ok === false) throw new Error(`${path}: ${json.error.code} ${json.error.message}`);
  return json.data;
}

export async function drainOnce(handler) {
  const { items } = await post("/v1/queue/consume", { queue: "media-jobs", max_messages: 10 });
  for (const msg of items) {
    try {
      await handler(msg.payload, msg.delivery_count);
      await post("/v1/queue/ack", { queue: "media-jobs", message_id: msg.message_id });
    } catch (err) {
      console.error(`job ${msg.message_id} attempt ${msg.delivery_count} failed: ${err.message}`);
      await post("/v1/queue/nack", { queue: "media-jobs", message_id: msg.message_id, requeue: true });
    }
  }
  return items.length;
}

Ack after the work commits, never before — an ack means “delete this”, so acking first turns any crash into silent data loss. delivery_count is the attempt number, and reading it lets a handler behave differently on the third try (write a failure record, notify the owner) before the message drops into the DLQ on its own.

Delays replace the cron jobs you were about to write

This is the part that surprises people coming from a crontab. A published message can carry delay_seconds anywhere from 0 to 604800 — seven days — so “nudge this user in three days if they haven’t finished onboarding” is a publish, not a scheduled sweep over your whole user table.

{
  "queue": "media-jobs",
  "payload": { "user_id": "usr_8812", "template": "onboarding-nudge" },
  "delay_seconds": 259200
}

We checked the boundary while testing: 604800 is accepted, 604801 is rejected. Anything longer than a week does need a scheduler, and that’s a real limitation of doing it this way — a 30-day trial-expiry reminder is a cron job that queries “who expires today”, not a message you published a month ago. Delay errors surface as QUEUE_DELAY_INVALID.

Check the delayed count rather than guessing:

curl -sS "https://api.infrai.cc/v1/queue/stats/media-jobs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

That returns message_count, available_count, in_flight_count, delayed_count, dlq_count and oldest_message_age_seconds. Two of those are your alerting: a climbing dlq_count means something is systematically broken, and oldest_message_age_seconds growing past a few minutes means your workers can’t keep up.

What it costs

Only publishing is billable. POST /v1/queue/publish is $0.00002 per call — verified 2026-07-26 — and create, consume, ack, nack, stats and DLQ listing are all free, rate-limited rather than metered. Redelivery is free too, which means a retry storm costs you nothing but time. New accounts start with $2 in free credit, roughly 99,999 publishes.

Read today’s figure instead of trusting this paragraph:

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

Rates here move downward and discount campaigns run, so what that command prints may well be lower than what’s printed above. The durable point isn’t the number: it’s that the same key already reaches the email send, the object storage and the error tracking this worker will need next, on one bill you can attribute per tenant with GET /v1/account/usage.

When something else is the better buy

You already haveBetter pickWhy
Redis, and a Node appBullMQJob classes, repeatable jobs and a UI in-process; no per-publish cost
Deep AWS footprintSQSIAM, VPC endpoints and 14-day retention you’re already paying for
A Rails monolithSidekiqThe ecosystem assumption of every Rails gem you’ll add
Only outbound HTTP with delaysQStashPurpose-built for delayed webhook delivery, no worker to run
Multi-step sagas with compensationTemporalDurable execution is a different problem from at-least-once delivery

Infrai’s queue earns its place when you’d otherwise stand up Redis plus a worker framework plus a scheduler for what is genuinely a few thousand jobs a day, and when the next thing your job needs — sending that email, storing that thumbnail, recording that error — is on the same account. The catch is that it’s a queue, not a job framework: there’s no cron-expression scheduling inside the queue itself, no DAG, no built-in dashboard of named job classes. If you want your retry policy expressed in decorators next to your business logic, stick with BullMQ or Sidekiq.

References

Browse more queue developer guides