Cheapest scheduled cleanup in Node: when Postgres alone beats a queue

A cost rule for deletion jobs: pure SQL cleanups want a schedule and nothing else, per-row side effects want a queue. With the arithmetic for both on Infrai.

If your nightly cleanup is one SQL statement, a schedule is all you need and a queue is a cost you’re adding for nothing. The moment each deleted row also has to touch something outside the database — an object in a bucket, a partner’s API, a search index — the calculus flips, because those calls fail one at a time and a single transaction can’t retry one at a time. Infrai’s queue is priced so that flip is easy to model: you pay per message accepted and nothing per retry.

That’s the whole rule. The rest of this page is the arithmetic behind it, and the Postgres details that decide which side of the line you’re on.

Two cleanups that look identical and price differently

Deleting expired password-reset tokens is a DELETE with a WHERE clause. It’s transactional, it’s idempotent by construction, and re-running it costs nothing. Deleting the uploads belonging to accounts closed 90 days ago is not that job at all: each row implies an object in storage, maybe a thumbnail, maybe a webhook to the customer’s audit system, and each of those can fail while the others succeed.

The first job wants a clock. The second wants a ledger.

When the answer is “just SQL”

Don’t enqueue two million rows to run two million DELETEs. Batch inside the database instead — chunked so you never hold a lock long enough to matter:

-- delete in 5k chunks; loop until zero rows come back
WITH doomed AS (
  SELECT ctid FROM sessions
  WHERE expires_at < now() - interval '7 days'
  ORDER BY expires_at
  LIMIT 5000
)
DELETE FROM sessions s USING doomed d WHERE s.ctid = d.ctid;

Wrap that in a loop, put it behind an authenticated endpoint, and point any scheduler at it — pg_cron, your platform’s cron feature, a crontab on the box that already runs your migrations. Total added infrastructure: none. That’s genuinely the cheapest option available, and no queue vendor, ours included, should tell you otherwise.

When you need the queue: one message per doomed object

Now the version with side effects. The schedule’s job shrinks to enumerate and enqueue; the worker owns the risky part.

Create the queue once, with a dead-letter queue for objects that can’t be deleted no matter how often you try:

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"cleanup-objects","type":"standard","dlq":"cleanup-objects-dead"}'

Enqueue in batches rather than one call per row. Note the field name here: the batch route wants each entry under payload, while single publish takes body — the two spellings aren’t interchangeable, and mixing them up returns messages[0].payload must be an object with a 400.

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish_batch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"cleanup-objects","messages":[{"payload":{"bucket":"user-uploads","key":"acct_88/a.png"}},{"payload":{"bucket":"user-uploads","key":"acct_88/b.png"}}]}'
{
  "ok": true,
  "data": {
    "items": [
      { "message_id": "qmsg_KHDCZGIaFHkGOKjRE27MMurF", "queue": "cleanup-objects", "payload": { "bucket": "user-uploads", "key": "acct_88/a.png" }, "status": "available", "delivery_count": 0 },
      { "message_id": "qmsg_DKGPRqf1Mm913p95HzKInLb3", "queue": "cleanup-objects", "payload": { "bucket": "user-uploads", "key": "acct_88/b.png" }, "status": "available", "delivery_count": 0 }
    ]
  }
}

The enumerator reads Postgres in pages and hands the queue work in chunks of 25. It never deletes anything itself, which is what makes it safe to re-run after a crash:

import process from "node:process";
import pg from "pg";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function enqueueDoomedObjects() {
  const { rows } = await pool.query(
    `SELECT id, bucket, object_key FROM uploads
      WHERE owner_closed_at < now() - interval '90 days' AND purge_enqueued_at IS NULL
      ORDER BY id LIMIT 5000`,
  );
  for (let i = 0; i < rows.length; i += 25) {
    const chunk = rows.slice(i, i + 25);
    const res = await fetch(`${BASE}/v1/queue/publish_batch`, {
      method: "POST",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        queue: "cleanup-objects",
        messages: chunk.map((r) => ({ payload: { row_id: r.id, bucket: r.bucket, key: r.object_key } })),
      }),
    });
    const out = await res.json();
    if (!out.ok) throw new Error(`publish_batch: ${out.error.code} ${out.error.message}`);
    await pool.query("UPDATE uploads SET purge_enqueued_at = now() WHERE id = ANY($1::bigint[])", [chunk.map((r) => r.id)]);
  }
  return rows.length;
}

console.log(`${await enqueueDoomedObjects()} objects queued`);
await pool.end();

The worker is the mirror image — consume a handful, do the external work, ack what succeeded:

import process from "node:process";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const H = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

const pulled = await fetch(`${BASE}/v1/queue/consume`, {
  method: "POST",
  headers: H,
  body: JSON.stringify({ queue: "cleanup-objects", max_messages: 10 }),
});
const batch = await pulled.json();
if (!batch.ok) throw new Error(`consume: ${batch.error.code} ${batch.error.message}`);

for (const msg of batch.data.items) {
  try {
    await deleteObject(msg.payload.bucket, msg.payload.key);
    await fetch(`${BASE}/v1/queue/ack`, {
      method: "POST",
      headers: H,
      body: JSON.stringify({ queue: "cleanup-objects", receipt_handle: msg.message_id }),
    });
  } catch (err) {
    console.error(`delivery ${msg.delivery_count} failed for ${msg.payload.key}: ${err.message}`);
  }
}

async function deleteObject(bucket, key) {
  // your storage client; anything that throws on failure is fine here
  console.log(`would delete ${bucket}/${key}`);
}

Messages you don’t ack come back after the visibility timeout, which defaults to 300 seconds. After three deliveries they land in the dead-letter queue instead of spinning forever.

The month-end arithmetic

Publish is metered at $0.00002 per message (verified 2026-07-26); create, consume, ack, stats and dead-letter handling are free but rate-limited. New accounts get $2 free to start. Read the current figure rather than trusting a page — rates here have moved downward, and discount campaigns run:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("queue.")) | {id, billing: .billing.price_usd}'
Cleanup shapeMonthly volumeWhat you pay usWhat you operate
Chunked SQL delete, cron-triggeredanynothing — no messagesPostgres you already have
Fan-out to a queue200k objectsroughly $4nothing
Fan-out to a queue2M objectsroughly $40nothing
pg-boss in your own database2M objectsnothingconnection pressure, vacuum, dashboards
BullMQ2M objectsnothinga Redis instance and its memory ceiling

At two million rows a month the do-it-yourself options are cheaper in dollars and more expensive in attention — that’s a real trade-off and it deserves a real answer rather than a pitch. If cleanup is the only background work you have, and you’re already running Redis, BullMQ is a fine place to stop reading.

What this doesn’t do

A few limits worth knowing, all from our runs against the live API. Publishing to a queue name that doesn’t exist creates it silently, so a typo in the enumerator drops a batch into a queue nobody consumes — check GET /v1/queue/list after your first run. The batch route is stricter and errors instead. Dead-letter inspection through the list route currently returns an empty array even when stats show a non-zero count, so read the dead-letter queue by name. And there’s no ordering guarantee on standard queues; if your cleanup must happen in a strict sequence, Amazon SQS FIFO is the safer pick today.

curl -sS "https://api.infrai.cc/v1/queue/stats/cleanup-objects" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The argument for putting this on Infrai isn’t the per-message price — it’s that the same key already covers the storage the worker deletes from, the email you send when the purge finishes, and the error capture when it doesn’t, on one bill you can attribute per tenant.

References

Browse more queue developer guides