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.

ApproachWhen it’s rightThe catch
Postgres partition drop or a TTL indexYour expiry is time-based and you control the schemaRestructuring an existing table is real work; not every ORM makes it pleasant
pg_cron inside the databasePostgres-only cleanup, no external moving partsRuns in the database, so a runaway job competes with your queries; no run history outside the DB
Infrai cron + queueCleanup spans more than the database, or you want run history and a DLQ without another vendorEndpoint must be public; it’s a trigger, not a workflow engine
BullMQ with repeatable jobsRedis is already in your stack and you want in-process job classesAnother 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.

References

Browse more queue developer guides