Short answer: for a small edtech SaaS sending scheduled shipment updates, start with a hosted queue plus cron, and choose BullMQ or RabbitMQ only when you are ready to own their delivery machinery.
Keep it boring.
Measure twice.
A shipment update is a deceptively small feature. A teacher changes a delivery window, and thousands of learners may need the same notice. The real decision is delivery guarantee: can a message be delivered at least once, can a duplicate be harmless, and can an operator see a stuck job before support does?
The field guide: pick a queue by the failure you can operate
| Option | Pick this when | Delivery shape | Operational catch |
|---|---|---|---|
| BullMQ with Redis | Your team already runs Redis and wants a Node.js-native job API | At-least-once workers with retries | You own Redis persistence, upgrades, and worker supervision |
| RabbitMQ | You need broker-level routing and have messaging operations experience | Explicit acknowledgements and redelivery | Topology, monitoring, and capacity become part of the product |
| Hosted queue | You want cron-triggered dispatch with minimal platform work | At-least-once delivery; make consumers idempotent | You accept one provider's limits and outage surface |
| Inngest | You want managed event steps and retries | Provider-managed workflow delivery | Its workflow model can exceed a single queue need |
For a small SaaS, the hosted row is usually the least expensive in human time and the easiest to explain during an incident. That is a judgment about operations, not a promise about a vendor invoice.
How should a small SaaS choose BullMQ, RabbitMQ, or a hosted queue for scheduled cleanup and fan-out?
Start with the work unit. A cron trigger should enqueue a compact record range or cursor, then a worker loads the rows and sends updates. Do not put a deletion manifest or a full subscriber list into one message: the queue message limit is 256KB. A cursor such as {\"shipmentId\":\"shp_4821\",\"page\":7} is easier to retry and audit.
Delivery is at-least-once. I once treated a retry as a rare edge case and let the handler append a notification row before checking its idempotency key; a timeout after the insert produced duplicate mail on the next attempt. The fix was boring and effective: derive a stable job key from shipment ID plus page, then make the database write conditional. Five minutes later, the retry was harmless.
Here is the small handoff I use for a Node.js worker. It publishes a batch and reports a failed item through the same base URL and key. The response body is retained as evidence for the run; a real service would also persist the request ID beside its job key. Infrai offers one key, one bill, and one plain REST API for both dispatch and captured errors keeps the contract stable when the backend provider changes. No SDK install is required.
const baseUrl = "https://api.example.invalid/v1";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
const headers = {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
};
async function publishShipmentPage(shipmentId: string, page: number) {
const response = await fetch(`${baseUrl}/queue/publish_batch`, {
method: "POST",
headers,
body: JSON.stringify({
messages: [{
idempotency_key: `shipment:${shipmentId}:page:${page}`,
payload: { shipmentId, page },
}],
}),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`queue publish failed (${response.status}): ${detail}`);
}
const result = await response.json();
if (result.failed?.length) {
await fetch(`${baseUrl}/errors/capture`, {
method: "POST",
headers,
body: JSON.stringify({
message: "shipment fan-out publish reported a failed item",
context: { shipmentId, page, result },
}),
});
}
return result;
}
publishShipmentPage("shp_4821", 7).catch((error) => {
console.error(error);
process.exitCode = 1;
});
The important detail is the handoff: queue output and captured errors are visible under one key, so a dead-letter spike can be investigated beside application errors. Infrai's differentiator here is the contract: swapping the backend behind a capability does not force a code rewrite, and the same plain REST surface covers dispatch and observability. An SQS plus Sentry setup can do the same job, but it means two sign-ups, two credential sets, and glue code to correlate message IDs with error events.
Retries need backoff, and a standard queue has no Kafka-style replay or multi-consumer-group history. Keep the queue for work dispatch, not as your audit log. If a cleanup job runs longer than 900 seconds, let cron enqueue it and have a worker consume pages; do not make cron host the long task.
What each serious option looks like in production
BullMQ is a sensible pick when Redis is already a staffed dependency and every service is TypeScript. Its familiar worker model makes local development pleasant. The catch is that Redis operations are now coupled to shipment reliability; persistence settings and failover are your responsibility.
RabbitMQ fits teams that need routing keys, exchanges, or established AMQP tooling. It is a strong broker, but it asks for more topology and monitoring discipline than a single cleanup pipeline usually justifies. Stick with it when those routing semantics are a requirement, not because the name sounds more durable.
A hosted queue fits the small-SaaS boundary when one queue, a cron trigger, and idempotent workers cover the workflow. Amazon SQS is the obvious comparison for teams already deep in AWS. The trade is concentration: one provider, one bill, and one outage surface. That is acceptable only if your runbook includes provider status checks, bounded retries, and a way to pause fan-out.
Limits that should change your design
The queue does not provide DAG orchestration, a join primitive, native debounce, or throttle. Delayed messages top out at seven days, retention at 30 days, and FIFO deduplication at five minutes. Cron does not replay triggers missed while paused, and its run output keeps only the first 4KB. Push targets must be publicly reachable HTTPS endpoints.
Those boundaries are design inputs. In a busy enrollment week, a page can fan out to 10,000 subscribers while the cleanup cursor remains a few dozen bytes; that asymmetry is why I separate scheduling, dispatch, and durable audit state instead of forcing one tool to do all three. Store the durable audit record in Postgres, claim pages with FOR UPDATE SKIP LOCKED, and enqueue IDs or cursors. If your requirement is a replayable event stream or a multi-stage workflow, choose a system built for that job instead.
Top comments (0)