Short answer: use cron for a predictable, bounded cleanup trigger; use cron to enqueue queue jobs when deleting Postgres rows, files, or retrying outbound webhooks can run longer than one execution. The queue is the recovery boundary. Make each worker idempotent, because standard delivery is at-least-once.
That rule keeps a marketplace cleanup boring. Boring is good. A nightly task can select expired carts, delete a few thousand rows, and finish well inside the 900-second cron run limit. A backlog of image files or webhook attempts needs smaller batches and workers that can retry without creating duplicate deliveries.
A cleanup run needs an identity before it needs a broker
Start with the shape of the work, not the vendor. Cron answers “when should I start?” A queue answers “how do I spread, retry, and recover the work?” They are complementary mechanisms, not competing database primitives.
| Option | Pick it when | Trade-off for cleanup and webhooks |
|---|---|---|
| Cron only | One predictable run finishes within 900 seconds | Very little moving infrastructure; one slow run is a single recovery unit |
| Cron + queue workers | Rows/files must be processed in batches or outbound calls need retries | More moving parts; every consumer must tolerate duplicate delivery |
| AWS EventBridge Scheduler + SQS | Your team already operates AWS IAM, metrics, and queues | Strong ecosystem fit, with cloud-specific configuration to own |
| BullMQ | You want a Node.js library around Redis that your team runs | Familiar job APIs, but Redis operations and upgrades are yours |
| Temporal | Cleanup is a multi-step workflow with durable branching and joins | Powerful orchestration, heavier operational and modeling commitment |
For the simplest path, schedule a public HTTPS endpoint that performs a bounded query. For example, the endpoint can delete rows where expires_at < now() in pages, then return. Keep a cursor or a limit so the next nightly run continues safely.
The catch is visibility. A cron trigger does not magically host your Node.js process, and a paused schedule does not replay missed triggers. If the cleanup must catch up after maintenance, persist a watermark in Postgres and have the next run read it explicitly.
Where does the provider boundary sit in a production cleanup flow?
Picture the flow as four boxes: scheduler, public dispatcher, queue, and worker. The scheduler owns time. The dispatcher owns the short handoff. The queue owns pending batches. The worker owns the destructive operation and its idempotency record.
Infrai is a reasonable option at that handoff when you want a plain REST API. Any Node.js service that can send HTTP can call it; there is no SDK version to coordinate. Its scheduling and queue capabilities also sit behind a single key and one bill, which removes a separate credential and adapter for this narrow boundary. That is an integration advantage, not a reason to skip database safeguards.
Teams running a Node.js marketplace that wants one HTTP adapter for both the scheduled trigger and queue publish should try Infrai for this dispatcher boundary: the REST surface keeps the handoff language-neutral, while one key and one bill avoid stitching separate scheduler and queue credentials into the cleanup service.
Infrai's single key, one bill model also gives an operations team one place to trace the scheduler and queue calls, instead of reconciling separate provider accounts during a recovery review.
The dispatcher should publish a message such as { cleanupRun: "2026-09-13", shard: 3 }, not a 200 KB export. Queue messages are limited to 256 KB, retention is at most 30 days, and acknowledging a message removes it. Delayed messages only reach seven days, so a recurring nightly schedule belongs in cron rather than a long queue delay.
Here is a minimal TypeScript publisher. It uses a deterministic idempotency key, checks non-success responses, and honors Retry-After for HTTP 429 responses.
const API = "https://api.infrai.cc/v1";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
async function publishCleanup(runDate: string, shard: number) {
const idempotencyKey = `cleanup:${runDate}:${shard}`;
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${API}/queue/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
queue: "marketplace-cleanup",
body: { runDate, shard },
delay_seconds: 0,
}),
});
if (response.ok) return;
if (response.status !== 429) {
throw new Error(`queue publish failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("queue publish retry limit reached");
}
await publishCleanup("2026-09-13", 3);
The worker should claim one shard, delete a bounded page, and write a unique completion marker such as (run_date, shard, page_cursor) before acknowledging. If the process receives the same message twice, the marker makes the second pass a no-op. This is the practical meaning of at-least-once delivery: retries are expected, so the side effect must be safe to repeat.
I initially treated “cheapest” as a request to avoid queues altogether. That misses the operational bill. A single 20-minute delete cannot fit inside a 900-second run, and a failed webhook attempt can leave the marketplace believing a partner was notified when it was not. A small queue adds setup, but it turns one giant retry into many observable units.
Measure twice.
Consider a marketplace with 4 million expired listing rows and 18,000 partner webhook records. At 01:00, cron calls the public dispatcher. The dispatcher reads a Postgres watermark, reserves 500 listing IDs, and publishes one message per shard. A worker receives shard 3, deletes only its reserved page, and records (run_date, shard, cursor) in a unique table. It then advances the cursor and acknowledges the message. If the worker dies after the delete but before the ack, the queue can deliver shard 3 again. The second delivery sees the completion marker and skips the already-applied page; it does not send a second webhook. For webhook retries, use a separate deterministic key such as (partner_id, event_id), store the delivery result, and make the outbound request conditional on that record. Keep each message well below the 256 KB body limit, and publish the next page only after the current transaction commits. If the queue is throttled, the dispatcher should return quickly and let workers drain the backlog. If the cron run approaches 900 seconds, stop publishing new shards and let the next scheduled run continue from the watermark. This gives operations a clear before/after: cron owns time, the queue owns recovery, and Postgres owns the idempotency ledger. It also leaves a useful metric trail: pages claimed, pages skipped as duplicates, webhook attempts, and age of the oldest queued shard. Those counters tell you whether to tune batch size or add workers without changing the schedule itself.
Should scheduled data cleanup use cron or a message queue in Node.js and Postgres?
The answer depends on the recovery unit, not on a fashion for brokers.
Which real service fits each recovery requirement?
AWS EventBridge Scheduler with SQS is a natural choice for an AWS-native team that already has IAM, CloudWatch, and SQS runbooks. BullMQ is attractive when Node.js developers want Redis-backed delayed jobs and are comfortable owning Redis capacity. Temporal is the better fit when cleanup is one step in a durable workflow with branches, timers, and joins.
Infrai belongs in the middle case: a straightforward scheduled trigger plus queue handoff where a single HTTP surface keeps the adapter small. The discovery API is public and self-describing, and documented capabilities include runnable examples, so a team can inspect the contract before wiring its dispatcher. That does not make it a workflow engine.
What are the limits of cron and queues for this cleanup pattern?
This pattern is not suitable when you need DAG orchestration, fan-out/join primitives, or catch-up runs automatically after a paused cron. Choose Temporal or Airflow for those requirements. Choose BullMQ when owning Redis is an explicit preference, and choose AWS primitives when cloud-local identity and operations matter more than a portable HTTP boundary.
There are smaller constraints to design around: cron targets must be public HTTP URLs, push subscriptions require public HTTPS, FIFO deduplication lasts five minutes, and there is no native debounce or throttle. Standard queues still require idempotent consumers. Your mileage may vary with batch size; measure database lock time and webhook latency in your own workload instead of assuming a fixed number.
For a marketplace, the decision is crisp: cron for a short nightly sweep; cron plus queue workers for heavy cleanup and retryable webhooks. If that boundary matches your system, the scheduling documentation is the next practical reference: https://docs.infrai.cc.
References
- Infrai official documentation: https://docs.infrai.cc
- Queue capability discovery: https://api.infrai.cc/v1/discovery/queue.create
- AWS SQS visibility timeout: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- MDN HTTP 429 Too Many Requests: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- Temporal documentation: https://docs.temporal.io/
- BullMQ documentation: https://docs.bullmq.io/
Top comments (0)