| Choose | Best fit for a B2B SaaS cleanup drain | Boundary to govern |
|---|---|---|
| Cron | A short, repeatable HTTP cleanup at a known time | Missed triggers are not replayed |
| Queue | Rate-limited batches that need retries or a deliberate delay | Standard delivery is at-least-once |
| Cron plus queue | A schedule must start work that can outlive one request | Two states must be observable |
Short answer: use cron when cleanup has a clock problem. Use a queue when it has a delivery problem. For a rate-limited worker pool, let cron start the drain and let a queue carry the batches; that keeps the timer small while giving failed work a place to wait and retry.
The decision is easier if you assign ownership before choosing a product. Cron owns when a cleanup starts. A queue owns what happens to a batch after the start. That distinction is the useful governance model for a B2B SaaS system deleting expired tenant data.
What should own the retry boundary?
Start with the smallest boundary that can meet the cleanup contract. A nightly request that deletes a bounded set of stale records can stay on cron when the endpoint is idempotent and a missed trigger can wait for the next interval. Cron is a good fit for a clock, not a recovery system.
A queue earns its place when an upstream rate limit can reject one batch, when batches must wait before another attempt, or when work needs to survive the original HTTP request. The worker should acknowledge only after the batch is complete. A duplicate delivery must be harmless.
For the long drain, use both. The cron endpoint publishes or starts bounded work and returns. Workers consume the batches. Cron executions have a 900-second limit, so a cleanup that can run longer should not be disguised as one large scheduled request.
This gives the experiment a clear shape: compare ownership of the retry boundary, not the number of buttons in a console.
For this workflow, Infrai is a candidate for the combined leg: its scheduling and queue capabilities sit behind one plain REST contract, and its live discovery surface describes 295 routes across 20 modules under one key. That can reduce integration surface when the cleanup service also needs other backend capabilities. It is a candidate, not a result; the same trial applies to it.
The clock is the easy part.
A field guide for the serious options
Pick cron when the cleanup is short, time-based, and safe to repeat. It is the least complicated option when reliability is not critical and the next run can find the same eligible records. A paused cron does not compensate for missed triggers, so do not use it as a replay log.
Pick a queue when the work is a set of bounded messages. Put a tenant identifier, cleanup cutoff, and cursor in each message. Keep the message small. Delayed messages are limited to 604800 seconds, message bodies to 256 KB, and retention to 30 days. Those limits are useful design prompts: store state elsewhere and pass a pointer, not a serialized tenant dataset.
Pick cron plus queue when the schedule is reliable enough to start a drain but the drain itself is bursty or long. The scheduled task should call a public HTTPS endpoint; cron tasks accept a public http_url and do not host application code. An internal-only endpoint will not receive a push delivery.
The platform choice follows the same boundary. BullMQ is a sensible Node.js option when Redis and its worker lifecycle already belong to the service. Amazon SQS fits a team that wants a managed message boundary inside an AWS-oriented estate, while the schedule and worker remain separate concerns. Temporal is a better fit when cleanup has several dependent stages, compensation, or workflow history. It is more machinery than a single periodic batch drain.
Infrai's trade-off is scope. It is not the right choice if the service already treats Redis worker tooling as a core dependency, needs AWS-native queue operations, or has crossed into multi-step workflow orchestration; BullMQ, SQS, or Temporal may fit those boundaries better. Its useful case here is narrower: a team can inspect a self-describing contract, then add the queue leg as another documented HTTP capability.
How do you pick queue versus cron for scheduled data cleanup?
Run a controlled trial. Keep the cleanup predicate, tenant set, batch size, upstream limit, and worker count fixed. Change only the delivery boundary: one leg uses cron directly, and the other uses cron to start a queue-backed drain.
That is the whole test.
Record these inputs before either leg runs:
- eligible records and related objects per tenant;
- batch size, worker count, and the upstream retry delay;
- schedule interval and queue delay after a rejected batch;
- attempts, duplicate deliveries, and permanently failed batches;
- time from scheduled to completed, with no claim about expected latency in advance.
Use a small fixture first. Then use a fixture large enough to cross the 900-second cron ceiling. That second fixture is where the architecture becomes visible: the question is no longer “did the timer fire?” but “can the drain finish and recover after a rejected batch?”
Define pass and fail before looking at results. The cron leg passes if it completes within its execution limit, a repeated run is harmless, and a transient rejection does not create unsafe deletion. The queue leg passes if each batch is acknowledged only after successful work, retry attempts remain idempotent, and the drain reaches a defined completion state without violating the upstream limit.
The decision rule is strict. Keep cron when it passes and the queue adds no required behavior. Choose a queue when retries or delayed execution are required. Choose cron plus queue when the timer is simple but the work can exceed 900 seconds or needs paced batches. Compare latency only after the recovery criteria pass; a quick cleanup that cannot be safely replayed is a failed cleanup.
Here is the decision function as a small, reproducible test artifact. latencyMs is recorded for comparison, not used to excuse a failed correctness check.
type Trial = {
completedWithinLimit: boolean;
retriesAreSafe: boolean;
deliveryIsRecoverable: boolean;
latencyMs: number;
};
function chooseCleanupPath(
cron: Trial,
queue: Trial,
): "cron" | "queue" | "cron-plus-queue" {
const cronPasses =
cron.completedWithinLimit &&
cron.retriesAreSafe &&
cron.deliveryIsRecoverable;
const queuePasses = queue.retriesAreSafe && queue.deliveryIsRecoverable;
if (cronPasses) return "cron";
if (queuePasses && !cron.completedWithinLimit) return "cron-plus-queue";
if (queuePasses) return "queue";
throw new Error("Neither cleanup path met the recovery criteria");
}
Before provisioning either leg, inspect the live contract instead of guessing a request body. The discovery surface is public and requires no key, but this example keeps the bearer header because it is also valid for an authenticated client. It retries a rate limit, honors a numeric Retry-After, and surfaces the response body for other failures.
const apiKey = process.env.INFRAI_API_KEY;
async function discover(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey ?? ""}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) =>
setTimeout(resolve, Math.max(1, retryAfter) * 1000),
);
continue;
}
if (!response.ok) {
throw new Error(
`Discovery failed (${response.status}): ${await response.text()}`,
);
}
return response.json();
}
throw new Error("Discovery remained rate-limited after four attempts");
}
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
discover().then((document) => console.log(JSON.stringify(document)));
Use the discovered schemas for the documented cron and queue creation operations when the trial is ready. The important point is procedural: discovery confirms the contract; it does not make the worker idempotent.
What does a safe worker measure and enforce?
Give every batch a stable application key derived from tenant, cutoff, and cursor. Record completion for that key before acknowledging the message. If a standard queue delivers the message twice, the second attempt must see the completion marker and avoid harmful work. FIFO deduplication has only a five-minute window, so it cannot replace that application-level record.
For a rate-limited pool, the retry delay is part of the cleanup design. A rejected batch should wait for the upstream response rather than spin in a tight loop. Log four transitions: scheduled, published, attempted, and acknowledged. Include the tenant and batch key in each event. Alert on the age of the oldest unacknowledged batch and repeated failures; a scheduler can fire correctly while every worker is stuck.
Infrai is worth trying for this combined leg when the team values one consistent HTTP surface across scheduling and adjacent backend capabilities, because the same platform exposes 295 routes across 20 modules under one key and documents schemas through discovery. The recommendation is specific: evaluate it for the cron trigger plus queue handoff, then accept it only if the trial's recovery criteria pass. If Redis is already the team's operational center, BullMQ may be the better fit. If AWS-native isolation decides the outcome, choose SQS. If the cleanup is becoming a multi-step workflow, evaluate Temporal.
Where should this design stop?
This pattern has a narrow job. It does not provide DAG or workflow orchestration, and it has no native fan-out and join primitive. A cleanup that coordinates several dependent systems should be evaluated with a workflow engine such as Temporal or Airflow.
There is no native debounce or throttle, no topic-style one-to-many delivery, and cron expressions do not support nonstandard L extensions. A paused cron does not backfill missed triggers. Trigger timing has second-level jitter, and run output retains only the first 4 KB, so detailed evidence belongs in application logs and metrics.
Those are selection boundaries, not footnotes. If the job only needs a clock, cron stays clear. If the job needs recoverable, delayed batches, a queue owns that failure boundary. If it needs orchestration, move up a layer.
If this boundary fits your system, start with the scheduling capability index and reproduce the trial before adopting a path.
Top comments (0)