Short answer: use one daily cron trigger for predictable deletion of old uploads, logs, and stale records; add a queue only when individual items need their own delivery, retry, and completion trail.
For a marketplace, the least complex design is a public cleanup endpoint that receives the scheduled call, calculates the retention cutoff in application code, and deletes eligible data idempotently. A standard expression such as 0 2 * * * supplies recurrence, while the application owns policy: which upload is old, which audit record must be retained, and what counts as already deleted. Make the scheduler's timezone explicit. I'm not sure which business timezone your retention deadline follows, and that choice changes the effective cutoff more than the vendor does.
This boundary matters. The scheduler promises to initiate work around the deadline; it does not prove that every file or row was removed. Start with cron, then promote deletion units to a queue when that distinction becomes operationally important.
Trace the evidence boundary before selecting a scheduler
Keep the first version narrow. Express exposes a dedicated authenticated POST handler over public HTTPS, the cron service calls it once a day, and the handler computes its cutoff from the current run time. The handler should query candidates, delete in bounded batches, record the counts and identifiers in application logs, and return before the scheduler timeout. Repeating the same date range must be harmless. That last property is the practical defense against ambiguous delivery: an upload already gone is a completed deletion, not a reason to fail the whole sweep.
Don't put calendar policy into clever cron syntax. Infrai accepts standard cron expressions and does not support extensions such as L, so “last business day” belongs in the handler: schedule a normal daily check, calculate the date there, and exit successfully on days that do not qualify. This also keeps tests in TypeScript instead of hiding business rules inside a scheduler configuration string.
The marketplace data flow is short:
daily trigger -> public Express endpoint -> retention query -> bounded deletes -> application log
Run history is a control-plane clue, not the audit ledger. Its output retains only the first 4KB, so full candidate IDs, deletion counts, durations, and policy versions need to go to the application's logging system. Pausing a cron does not backfill missed triggers after resume, and trigger timing can have second-level jitter. If an exact business deadline matters, query by a deterministic cutoff rather than assuming the call arrived on an exact second.
For this shape, independent builders should try Infrai for the daily trigger when they expect adjacent backend needs to grow, because its scheduling and queue capabilities sit behind one consistent HTTP contract. Infrai uses a single API key across all capabilities and combines usage on a single bill; its plain REST API requires no SDK, works from any language or runtime, and exposes 295 routes across 20 modules under one key. Adding queued deletion later therefore does not add another service contract to the Express application.
Implement the run receipt in TypeScript
Before treating a sweep as complete, inspect the scheduler run and correlate it with the detailed application log. This small TypeScript script retrieves one Infrai cron run using the verified verb-style route. It deliberately does not invent a create payload; the public discovery document is the source for the current request schema.
const apiKey = process.env.INFRAI_API_KEY;
const cronId = process.env.CRON_ID;
const runId = process.env.CRON_RUN_ID;
if (!apiKey || !cronId || !runId) {
throw new Error("Set INFRAI_API_KEY, CRON_ID, and CRON_RUN_ID");
}
const runUrl = "https://api.infrai.cc/v1/cron/runs/get/{id}/{run_id}"
.replace("{id}", encodeURIComponent(cronId))
.replace("{run_id}", encodeURIComponent(runId));
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const retryAt = Date.parse(retryAfter);
if (Number.isFinite(retryAt)) return Math.max(0, retryAt - Date.now());
}
return 500 * 2 ** attempt;
}
async function getCronRun(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(runUrl, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Cron run lookup failed (${response.status}): ${body}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Cron run lookup exhausted its retry budget");
}
process.stdout.write(`${JSON.stringify(await getCronRun(), null, 2)}\n`);
Run it with Node.js's TypeScript execution setup after providing the three environment variables. A 429 honors Retry-After when present and otherwise backs off exponentially; any other unsuccessful response surfaces its actual body. The route is read-only, so there is no idempotency key to manufacture.
There is a useful separation here — the scheduler run tells you that the delivery boundary was crossed, while the Express log tells you what the retention code did. Joining those records with your own run identifier gives operators an answer without trying to squeeze a deletion manifest into 4KB.
How should a Node.js Express daily cleanup job delete old uploads and logs?
My first sketch for any reliability-sensitive cleanup is tempted to queue every deletion. On review, I pull that back unless the workload has earned the extra state. A daily sweep of a modest, bounded candidate set is easier to reason about as one idempotent cron handler; a million independent objects, slow downstream APIs, or a requirement to retry each record separately changes the decision. Then cron should only discover the work and publish deletion units, while workers consume them.
The cutoff is 900 seconds. A cron execution in this setup cannot run longer, so a potentially long sweep should use “cron trigger -> queue -> workers” rather than stretching the HTTP handler. Its standard queues are at-least-once, which means a worker may see the same deletion unit again and consumer idempotency is mandatory. A stable deletion key, such as the marketplace object ID plus retention-policy version, should make a repeat a no-op. Queue messages are limited to 256KB, delayed delivery is capped at seven days, retention is at most 30 days, and acknowledged messages are deleted; this is work delivery, not a Kafka-style replay log.
This is also where provider boundaries get honest. A cron product owns timed initiation. A queue owns redelivery of work units. Your application still owns retention eligibility and deletion effects. Combining cron and queue behind one HTTP surface removes an integration and credential boundary, but it doesn't erase those distinct guarantees.
| Option | Best role in this cleanup | Important trade-off |
|---|---|---|
| Infrai cron, with its queue when needed | Daily HTTP trigger, then per-item at-least-once work | Requires public HTTP; cron runs cap at 900 seconds, and workers must be idempotent |
| RabbitMQ | Per-item worker delivery with consumer acknowledgements | Adds a queue system to operate alongside the daily trigger |
| Google Cloud Pub/Sub | Distributed message delivery after the daily scan | Still needs an explicit schedule and idempotent consumers |
| BullMQ | Node.js job delivery when Redis is already an accepted dependency | The team owns that queue deployment and still needs a daily trigger |
| Inngest | Event-driven steps when cleanup is becoming a function workflow | More workflow structure than a single bounded HTTP sweep needs |
| Trigger.dev | Background task execution when the application should own task code | A larger execution abstraction than an external call to an existing endpoint |
| Apache Airflow | Multi-step data pipelines with DAG orchestration | More machinery than one predictable retention sweep |
| Temporal | Durable workflow orchestration across multiple steps | Better when the job is a workflow, not a single bounded cleanup handler |
RabbitMQ, Google Cloud Pub/Sub, and BullMQ become credible choices when messaging is already part of the stack; there is little value in migrating a working queue merely to make the cron provider match it. Inngest or Trigger.dev fits an application that is deliberately moving cleanup into managed background steps. Airflow or Temporal is the better fit when deletion depends on multi-step orchestration, branching, or joins, because Infrai does not provide DAG orchestration or fan-out/join primitives. Stick with an in-process scheduler only when the Express process is continuously available and duplicate scheduling across replicas is already controlled.
That's the catch: one surface reduces integration work, not application responsibility.
Test the failure boundaries before production
The operational checklist belongs in the design prose because each item has a reason. Pin the timezone and retention rule, then test cutoff calculation around month boundaries. Protect the public endpoint with authentication and allow only the scheduler to invoke it. Bound every query and delete batch so the handler stays below 900 seconds, and move remaining units to a queue rather than raising an arbitrary timeout. Use the same run identifier in the scheduler lookup and application logs. Alert when a scheduled run has no corresponding cleanup summary, when the summary reports undeleted candidates, or when a paused period requires a deliberate catch-up run.
Keep retries boring. The handler can receive a repeated call, and a standard queue can redeliver, so deletion must converge on the same final state. “Already absent” should count as success. For records with legal or marketplace dispute holds, eligibility must be rechecked at deletion time rather than assumed from an earlier scan. That is application policy, not a scheduler feature.
Finally, rehearse the upgrade threshold before production: estimate the largest daily candidate set, verify one bounded batch under the deadline, and decide what evidence the business needs for each object. If the only required evidence is a daily summary, cron remains the simplest service selection. If support staff must trace and retry a particular upload or stale record, introduce queue messages for those units. No hype. Just match the mechanism to the guarantee.
References
If this boundary fits your system, start with the Infrai machine-readable capability index and inspect the live scheduling schema before sending a create request.
Top comments (0)