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", payload: { 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.delivery_count > 5) { // poison batch: send it straight to the DLQ
await post("/v1/queue/nack", { queue: "session-cleanup", message_id: msg.message_id, requeue: false });
continue;
}
try {
await db.query("DELETE FROM sessions WHERE id = ANY($1)", [msg.payload.ids]);
await post("/v1/queue/ack", { queue: "session-cleanup", message_id: msg.message_id });
} catch (err) {
console.error(`batch failed: ${err.message}`);
await post("/v1/queue/nack", { queue: "session-cleanup", message_id: msg.message_id });
}
}
}
message_id is the handle for both ack and nack, and payload is where the body you published comes back — those two names are the whole API surface a worker needs.
Ack after the commit, never before. Ack means delete-the-message, so acking first turns any crash into rows that stay expired forever with nothing left in the queue to say so. Settle a message twice and the second call returns 404 MESSAGE_NOT_FOUND with retryable: false, which is a useful signal that your lease expired mid-transaction and another worker already took the batch.
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-dead","type":"standard"}'
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","dead_letter_queue":"session-cleanup-dead","max_retries":3}'
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": "https://api.example.com/jobs/session-cleanup",
"timezone": "UTC",
"overlap_policy": "skip"
}'
The request field for the target URL is task; the job that comes back reports it as task_url, alongside job_id, status and 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”. A malformed cron_expr or a timezone that isn’t an IANA name is rejected on the spot with a 400, so a schedule that returns 200 is a schedule that parsed.
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 your endpoint at all, which separates a scheduling problem from a handler problem; and duration_ms climbing toward your interval is the leading indicator that overlap_policy: "skip" is about to halve your effective run frequency, because a run that starts while the previous one is still going is skipped by design.
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. A session row is rarely the only thing that expires: the avatar the user uploaded goes with DELETE /v1/storage/object/delete/{bucket}/{key}, the “your data has been purged” notice goes with POST /v1/email/send, and the run itself lands in POST /v1/logs/ingest — all on one credential, without another vendor to onboard for each step. That, plus run history and a dead-letter queue you didn’t have to stand up Redis for, is the trade being made here. Inngest and Temporal are the better answer if this cleanup is actually one step of a multi-step workflow with compensation.