Cron or queue for cleaning up old records once your Node app has replicas
A nightly purge that lives inside your process runs once per replica. Where to move the schedule, what overlap_policy actually records, and when the cleanup earns a queue.
Short answer: use a scheduler for the when and a queue for the how much. A single DELETE ... WHERE created_at < now() - interval '90 days' needs nothing but a trigger that fires once across your whole fleet, and Infrai’s POST /v1/cron/create gives you that as one HTTP call. The moment the cleanup has per-tenant rules, per-row side effects, or a row count that won’t finish inside one request, the trigger should enqueue rather than delete.
Getting that split wrong is cheap on one box and expensive on three.
The bug that appears the day you scale out
node-cron inside an Express process is genuinely the simplest thing that works — until the deploy that takes you from one container to three. Now the 03:00 purge fires three times, concurrently, against the same rows. Postgres will serialise the deletes and the result usually looks fine, which is the worst outcome, because the same pattern applied to an S3 purge or a “your data was deleted” email sends three of everything.
The usual patches are a Postgres advisory lock around the job body, or electing one replica as the scheduler. Both work. Both mean the schedule’s correctness now depends on code you maintain and on a lock you have to remember to release on crash.
Moving the schedule out of the process makes the fleet size irrelevant.
One trigger, whatever your replica count
curl -X POST https://api.infrai.cc/v1/cron/create \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "nightly-record-purge",
"cron_expr": "0 3 * * *",
"task": "https://api.example.com/internal/purge",
"timezone": "UTC",
"overlap_policy": "skip",
"timeout_seconds": 300,
"secret": "rotate-me-in-your-secret-store",
"payload": {"batch_size": 5000}
}'
Two details that aren’t obvious from the field names. The scheduler fires your task URL with POST, not GET — point it at a GET-only handler and every run records http_status: 405. And timeout_seconds defaults to 300, so a purge endpoint that streams progress for six minutes counts as a failure even though the deletes committed.
The secret is worth setting from the start. It’s the only thing standing between your purge endpoint and anyone who guesses the URL, and it never comes back in a response — you get a fingerprint instead, which means rotation is a create-side operation you control.
What actually happens when last night’s run is still going
overlap_policy defaults to skip, and the interesting part is what “skip” records rather than what it prevents. We fired two triggers at the same job within the same millisecond:
{
"items": [
{
"run_id": "cronrun_eaOUwAu8ZLRtYeCnxmb1XSX9",
"status": "failed",
"is_manual_trigger": true,
"started_at": "2026-07-26T05:50:36.653861Z",
"duration_ms": 0,
"skipped_reason": null,
"http_status": 405,
"error_code": "CRON_TASK_URL_UNREACHABLE"
},
{
"run_id": "cronrun_0eN8Lj1WmwPRX3p9FdRGP3KS",
"status": "skipped",
"started_at": null,
"duration_ms": null,
"skipped_reason": "overlap_skip",
"http_status": null,
"error_code": null
}
],
"next_cursor": null
}
The second attempt also returned HTTP 400 to the caller, with the message has an in-flight run; skipped. So a skipped run is visible in two places, and neither is a silent drop — which matters, because a cleanup that quietly stops running is the kind of thing you discover from a disk-usage alert months later.
overlap_policy | Second concurrent run | Recorded as | Use it when |
|---|---|---|---|
skip (default) | Refused, HTTP 400 | run row, skipped_reason: overlap_skip | The purge is slow and re-running it adds nothing |
allow | Executes immediately | two normal run rows | Runs are short and genuinely independent |
queue | Waits, then executes | two normal run rows | You want every tick to happen, in order |
For a nightly purge, skip is right. For a five-minute sweep whose backlog you don’t want to lose, queue is right.
When the delete needs a queue in front of it
Per-tenant retention is the case that breaks the single-statement design. Thirty days for the free tier, ninety for pro, a year for the two enterprise accounts with a contract — that’s not one DELETE, it’s N of them, and one slow tenant shouldn’t hold the others hostage.
curl -X POST https://api.infrai.cc/v1/queue/create \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"retention-sweep-tenants","type":"standard","visibility_timeout_default":300}'
Note what you don’t pass. Omit dead_letter_queue and the queue is still created with one — retention-sweep-tenants.dlq — and max_receive_count is fixed at 3. Dead-lettering is on whether you asked for it or not, so a tenant whose purge throws three times lands somewhere you can inspect instead of looping forever.
The planner turns the tenant table into messages:
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY (your_infrai_api_key)");
const tenants = [
{ tenant_id: "t_acme", retention_days: 30 },
{ tenant_id: "t_globex", retention_days: 90 },
];
const res = await fetch(`${BASE}/v1/queue/publish_batch`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
queue: "retention-sweep-tenants",
messages: tenants.map((t, i) => ({ payload: t, delay_seconds: i * 15 })),
}),
});
if (!res.ok) throw new Error(`publish_batch → HTTP ${res.status}`);
const { data } = await res.json();
for (const m of data.items) console.log(m.message_id, "visible at", m.available_at);
The staggered delay_seconds is a poor engineer’s rate limiter, and it’s usually enough — spreading fifty tenants over twelve minutes keeps the database out of a thundering herd without any coordination. The batch response carries available_at per message, which the single-message publish response does not; POST /v1/queue/publish echoes "status": "available" even for a message that won’t be visible for a week.
The worker claims a tenant, deletes in bounded chunks, and acknowledges by message_id:
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const QUEUE = "retention-sweep-tenants";
const H = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
async function post(path, body) {
const r = await fetch(`${BASE}${path}`, { method: "POST", headers: H, body: JSON.stringify(body) });
if (!r.ok) throw new Error(`${path} → HTTP ${r.status}`);
return (await r.json()).data;
}
async function purgeTenant(db, { tenant_id, retention_days }) {
const cutoff = new Date(Date.now() - retention_days * 86400000).toISOString();
let removed = 0, batch = 0;
do {
const q = await db.query(
"DELETE FROM events WHERE tenant_id = $1 AND created_at < $2 AND id IN (SELECT id FROM events WHERE tenant_id = $1 AND created_at < $2 LIMIT 5000)",
[tenant_id, cutoff],
);
batch = q.rowCount;
removed += batch;
} while (batch > 0);
return removed;
}
export async function drain(db) {
const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
for (const msg of items) {
try {
const removed = await purgeTenant(db, msg.payload);
const { acked } = await post("/v1/queue/ack", { queue: QUEUE, message_id: msg.message_id });
console.log(msg.payload.tenant_id, "removed", removed, "acked", acked);
} catch (err) {
await post("/v1/queue/nack", { queue: QUEUE, message_id: msg.message_id, requeue: true });
console.error(msg.payload.tenant_id, String(err.message ?? err));
}
}
}
max_messages is capped at 10 — ask for 11 and you get a 400 that says so — so a drain loop, not a single call, is the shape you want.
Verify it’s moving:
curl -s https://api.infrai.cc/v1/queue/stats/retention-sweep-tenants \
-H "Authorization: Bearer $INFRAI_API_KEY"
delayed_count and dlq_count are the two numbers worth alerting on. A dlq_count that climbs after a deploy means your new purge code throws on a tenant shape you didn’t expect.
What it costs, and the batching myth
Every cron and queue management route is free and rate-limited. Publishing is the only billable step: $0.00002 per message, verified 2026-07-26, with $2 of free credit on a new account. Fifty tenants nightly is roughly $0.03 a month.
Batching is a round-trip optimisation, not a billing one. A three-message publish_batch metered at $0.00006 in our testing — three messages at the single-publish rate — so batch for latency and connection count, not to save money. Read the current figures rather than trusting this paragraph, since rates drift downward and campaigns run:
curl -s "https://api.infrai.cc/v1/discovery" -H "Authorization: Bearer $INFRAI_API_KEY"
curl -s "https://api.infrai.cc/v1/account/usage" -H "Authorization: Bearer $INFRAI_API_KEY"
Where a different answer is better
If your cleanup is one SQL statement against one database and you already run a single scheduler process you trust, node-cron plus an advisory lock is fewer moving parts than any API, and it costs nothing. Take it. If your workers already sit next to Redis, BullMQ’s repeatable jobs cover both halves of this article in one library, with the trade-off that Redis persistence becomes your problem. SQS plus EventBridge Scheduler is the equivalent on AWS and is the better pick if the data you’re deleting never leaves that account anyway.
The case for doing it here is narrower than “it’s cheaper”: the same key that fires the schedule also publishes the messages, stores the export you take before deleting, and emails the tenant admin that retention ran — one credential, one bill, one usage view. If cleanup is genuinely the only thing you need, a specialist will serve you fine.