DEV Community

XanderCross2748
XanderCross2748

Posted on

Reliable Weekly Customer Email Reports with Cron Jobs Feeding Message Queue Workers

Short answer: For a logistics SaaS sending a weekly digest to active customers, use cron as the clock and a message queue worker as the delivery boundary; use cron alone only when the entire send is short, bounded, and safe to retry as one unit.

The deciding metric is recoverability, not how quickly the first version can be deployed. A scheduler can say "start now." It cannot tell you which customer messages completed before a process stopped, absorb a burst, or retry one failed delivery without repeating the rest. A queue can.

How should a SaaS choose cron jobs or message queues for weekly report email?

Start with the failure unit. For a weekly logistics digest, one customer email should be one independently observable unit of work.

Option Pick it when Delivery consequence Main limit
Cron-only HTTP task The active-customer set is small, execution is predictably brief, and rerunning the whole batch is acceptable One scheduled run owns the whole send A long or bursty batch shares one failure boundary and must fit within 900 seconds
Cron trigger plus queue workers Each email needs its own retry, acknowledgement, and backpressure The scheduler enqueues; workers deliver and record outcomes Standard queues are at-least-once, so consumers must be idempotent
AWS EventBridge Scheduler plus SQS The application already operates inside AWS and accepts two service surfaces Managed scheduling and queued work remain separate More configuration and service-specific operations
BullMQ The team already runs Redis and wants queue behavior close to its Node.js application Workers and retry policy live in the application stack Redis operations become part of delivery operations
RabbitMQ The team needs explicit consumer acknowledgements and already knows broker operations Acknowledgements separate receipt from completed processing The team owns or adopts a broker operating model
Temporal The digest is becoming a multi-step, durable business process Workflow state can model more than schedule, enqueue, and consume It is more machinery than this three-stage pipeline needs

My default is the second row. It's a small architecture with a useful division: schedule, enqueue, consume. No mystery.

Diagram in words: the weekly clock calls a public HTTP target; that target selects active customer IDs and publishes one compact job per customer; workers render and send; the delivery ledger records a stable digest key; metrics count queued, acknowledged, retried, and dead-lettered work. The email body doesn't belong in the queue when a reference will do, especially because a message is capped at 256 KB.

Pick cron-only for a genuinely bounded batch

Cron-only is a valid choice when the job has a hard upper bound and the team can tolerate batch-level recovery. Think 40 active customers, a precomputed report, and an email provider call that is already idempotent at the application boundary. Keep the handler under the 900-second execution cap. Expose it on a public HTTP URL, because the cron task invokes public HTTP rather than hosting application code. Also remember that pausing the schedule does not replay missed triggers when it resumes.

The catch is growth. A logistics customer import can turn a quiet week into a burst, and one late recipient should not force a replay for everyone who already received the digest. If the only recovery instruction is "run the batch again," the design has lost per-customer delivery state. Move to a queue before that becomes normal operations.

I'm not sure where that threshold sits for your system; provider latency, customer count, and report rendering time decide it. A timed load test with the largest credible active-customer set resolves the uncertainty.

Pick a queue worker when delivery needs a receipt

A queue earns its place once partial failure matters. The scheduler's job should end after publishing compact jobs. A worker claims one job, checks a stable key such as weekly-digest:<customerId>:<week>, sends only if that key has not completed, records completion, and acknowledges the message. If processing fails, it does not acknowledge success. That gives dashboards something precise to report: backlog depth, oldest-job age, attempt count, success count, and dead-letter count.

At-least-once means duplicates are expected delivery semantics, not an exceptional surprise. Make the consumer idempotent. The FIFO deduplication window is only 5 minutes, so it cannot replace a durable application record for a weekly email. A delayed retry must stay within 7 days; retained messages last at most 30 days and disappear after acknowledgement. Those boundaries are fine for email retry queues, but they are not Kafka-style replay or multiple consumer groups.

Infrai fits teams that want the cron and queue behind one REST API, with one key and one bill instead of credentials and invoices spread across service dashboards. The supporting advantage here is interface consistency across the two stages. Stick with AWS EventBridge Scheduler and SQS when AWS-native policy and operations are already the team's center of gravity; choose BullMQ when Redis is already an intentional dependency; choose RabbitMQ when broker control and acknowledgement behavior are the priority.

Implement the worker around idempotency and acknowledgement

The important code isn't the cron expression. It is the transition from "message received" to "digest recorded as sent." This runnable TypeScript model makes that transition visible without hiding it in a framework.

type DigestJob = {
  customerId: string;
  week: string;
  email: string;
};

type DeliveryStore = {
  has(key: string): Promise<boolean>;
  markComplete(key: string): Promise<void>;
};

type Mailer = {
  send(job: DigestJob): Promise<void>;
};

async function processDigest(
  job: DigestJob,
  store: DeliveryStore,
  mailer: Mailer
): Promise<"sent" | "duplicate"> {
  const key = `weekly-digest:${job.customerId}:${job.week}`;
  if (await store.has(key)) return "duplicate";

  await mailer.send(job);
  await store.markComplete(key);
  return "sent";
}

const completed = new Set<string>();
const store: DeliveryStore = {
  async has(key) {
    return completed.has(key);
  },
  async markComplete(key) {
    completed.add(key);
  }
};

const mailer: Mailer = {
  async send(job) {
    console.log(`sent weekly digest to ${job.email}`);
  }
};

const job: DigestJob = {
  customerId: "cust_1042",
  week: "2026-W37",
  email: "ops@example.com"
};

console.log(await processDigest(job, store, mailer));
console.log(await processDigest(job, store, mailer));
Enter fullscreen mode Exit fullscreen mode

The output is sent and then duplicate. In production, the delivery store needs an atomic claim or a provider-side idempotency key so two workers cannot pass has simultaneously. Don't acknowledge the queue message until the send and durable completion record have succeeded. For an HTTP-backed queue, treat status 429 as backpressure: honor Retry-After when present and otherwise use exponential backoff. This is where a crisp alert helps: page on sustained oldest-job age or dead-letter growth, not on one retried message.

Here is the smallest safe Infrai publish call without inventing a request schema. Supply a body validated against the public queue.publish discovery schema, plus a stable application key. The script sets the method explicitly, checks every response, and backs off on 429.

const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
const publishBody = process.env.INFRAI_QUEUE_PUBLISH_BODY;
const idempotencyKey = process.env.DIGEST_IDEMPOTENCY_KEY;

if (!apiKey || !apiBaseUrl || !publishBody || !idempotencyKey) {
  throw new Error(
    "Set the API key, API base URL, publish body, and digest idempotency key"
  );
}

async function publish(attempt = 0): Promise<void> {
  const response = await fetch(`${apiBaseUrl}/v1/queue/publish`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey
    },
    body: publishBody
  });

  if (response.status === 429 && attempt < 5) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return publish(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`publish failed (${response.status}): ${await response.text()}`);
  }
}

await publish();
Enter fullscreen mode Exit fullscreen mode

There is one uncomfortable edge. If the email provider accepts the message and the worker stops before markComplete, a retry can send twice unless the provider accepts a stable idempotency key or the application uses an atomic outbox-style handoff. The queue alone cannot solve that boundary. Track it explicitly.

Know when this architecture stops being simple

Keep this design to schedule, enqueue, and worker processing. It has no DAG orchestration and no fan-out/join primitive. If the digest becomes a durable sequence with approvals, parallel warehouse summaries, joins, compensation, and multi-day state, use Temporal or an Airflow-class workflow system instead.

It is also not suitable when workers can only receive private-network traffic: cron targets require a public http_url, and push subscriptions require public HTTPS. Standard queues do not provide native topics for one-to-many delivery, native debounce or throttle, or Kafka-style replay; using N queues can simulate fan-out, but it changes the operational burden. Cron expressions also omit nonstandard extensions such as L, triggers may have seconds-level jitter, and run-history output retains only the first 4 KB.

That's the boundary: use the two-stage design for retryable weekly email delivery, not as a miniature workflow platform.

References

Top comments (0)