Cleaning up expired sessions and tokens with a Node cron and queue
A nightly cleanup that deletes in bounded batches instead of one giant transaction: Infrai cron fires a public webhook, a queue does the work, and nothing holds a long lock.
The job everyone writes eventually: delete expired sessions and revoked tokens on a schedule. It looks trivial until the first cleanup that has to remove two million rows, at which point a single DELETE holds a lock long enough to take your login endpoint with it. On Infrai the pieces are POST /v1/cron/create to fire a public HTTPS endpoint on a schedule and POST /v1/queue/publish to break the work into pieces. Creating the schedule, consuming and acking are all free routes; only publishing is billed, per call.
For a nightly cleanup that enqueues a few hundred batches, cost is not the interesting part of this decision — blast radius is. That’s the real limitation of the naive version: one DELETE is cheap to write and expensive to survive.
The shape that doesn’t lock your table
Don’t have the cron endpoint do the deleting. Have it enumerate and enqueue:
import process from "node:process";
const BASE = "https://api.infrai.cc";
const headers = { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" };
const BATCH = 500;
async function post(path, payload) {
const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
if (!res.ok) throw new Error(`${path} failed ${res.status}: ${await res.text()}`);
return res.json();
}
/** The cron target. Returns in milliseconds; it does no deleting itself. */
export async function handleCleanupTick(req, res) {
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
// Enumerate ids only — cheap, index-only, no locks held.
const ids = await db.query(
"SELECT id FROM sessions WHERE expires_at < $1 ORDER BY id LIMIT 100000",
[cutoff],
);
let enqueued = 0;
for (let i = 0; i < ids.rows.length; i += BATCH) {
const slice = ids.rows.slice(i, i + BATCH).map((r) => r.id);
await post("/v1/queue/publish", { queue: "session-cleanup", body: { ids: slice, cutoff } });
enqueued++;
}
res.json({ enqueued, candidates: ids.rows.length });
}
The worker deletes one bounded batch per message and acks only after the transaction commits:
export async function drainCleanup() {
const { items = [] } = await post("/v1/queue/consume", { queue: "session-cleanup", max_messages: 10 });
for (const msg of items) {
if (msg.receive_count > 5) { // poison batch: stop retrying it
await post("/v1/queue/nack", { queue: "session-cleanup", receipt_handle: msg.receipt_handle });
continue;
}
try {
await db.query("DELETE FROM sessions WHERE id = ANY($1)", [msg.body.ids]);
await post("/v1/queue/ack", { queue: "session-cleanup", receipt_handle: msg.receipt_handle });
} catch (err) {
console.error(`batch failed: ${err.message}`);
await post("/v1/queue/nack", { queue: "session-cleanup", receipt_handle: msg.receipt_handle });
}
}
}
Ack after the commit, never before. Ack means delete-the-message, so acking first turns any crash into rows that silently never get cleaned.
Wiring the schedule
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"session-cleanup","type":"standard","dlq":"session-cleanup-dlq"}'
curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "nightly-session-cleanup",
"cron_expr": "0 3 * * *",
"task_type": "http",
"task_url": "https://api.example.com/jobs/session-cleanup",
"timezone": "UTC",
"overlap_policy": "skip"
}'
The documented create body is name, cron_expr, task_type, task_url, payload, timezone and overlap_policy, returning {job_id, status, next_run_at}. Check next_run_at — a populated value is your confirmation the schedule is actually live, and it’s the only check that distinguishes “created” from “will run”.
Your endpoint has to be publicly reachable. There’s no tunnel into a private network here, so an internal-only service needs a thin public shim.
Verify it ran, because silence is not success
curl -sS "https://api.infrai.cc/v1/cron/runs/list/cron_7f3a91c2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Free, and it returns items with run_id, status, fired_at, duration_ms, http_status and error. Two things to watch: http_status tells you whether Infrai reached you at all, which separates “the schedule is broken” from “my handler is broken”; and duration_ms climbing toward your interval is the leading indicator of overlap_policy: "skip" quietly halving your run frequency.
A hanging endpoint shows up as CRON_RUN_TIMEOUT, which is much better than an endpoint that just never returns.
Do you even need this?
Be honest about the cheapest option first.
| Approach | When it’s right | The catch |
|---|---|---|
| Postgres partition drop or a TTL index | Your expiry is time-based and you control the schema | Restructuring an existing table is real work; not every ORM makes it pleasant |
pg_cron inside the database | Postgres-only cleanup, no external moving parts | Runs in the database, so a runaway job competes with your queries; no run history outside the DB |
| Infrai cron + queue | Cleanup spans more than the database, or you want run history and a DLQ without another vendor | Endpoint must be public; it’s a trigger, not a workflow engine |
| BullMQ with repeatable jobs | Redis is already in your stack and you want in-process job classes | Another dependency to operate; retry policy lives in your app |
If a Postgres partition drop covers your case, do that instead — it’s faster than any external scheduler and it costs nothing. Infrai’s cron earns its place when the cleanup touches more than one system (delete the rows, then the object in storage, then notify), or when you want the run history and dead-letter queue without standing up Redis and a worker framework for one nightly job. Inngest and Temporal are the better answer if this cleanup is actually one step of a multi-step workflow with compensation.