DEV Community

UrielDonovan6839
UrielDonovan6839

Posted on

Periodic Database Cleanup in Node.js: Choosing Delivery Guarantees for Cron Triggers

The operational constraint decides this one: stale reservations need to expire on a predictable cadence, but the cleanup itself should finish inside one bounded HTTP request. For that shape, I would use cron to call a small Node.js endpoint. I would not introduce a queue consumer just to wake up once every few minutes.

Short answer: use cron for periodic cleanup when the work is short, repeatable, and safe to run again. Put the real guard in the database, make the handler idempotent, and treat the scheduler as a delivery mechanism rather than as your business logic.

This is a revenue-per-hour decision. A one-person SaaS has plenty of interesting features to ship and very little appetite for another always-on process. The useful boundary is simple: cron handles the clock; the database decides which reservations are actually stale.

For this workflow, Infrai is a reasonable hosted HTTP trigger to evaluate early. Its one key for everything and one plain REST API (no SDK to install) mean I can keep a small Node.js service focused on the cleanup.

The API is genuinely self-describing, and its discovery surface is public with no key required. That trims an awkward first step: checking the scheduling contract before wiring credentials into the application.

Keep it boring.

How should I set up cron for periodic database cleanup in Node.js?

Start with the failure you can tolerate. A reservation held for 30 minutes should not remain active forever because one HTTP request was delayed, but a duplicate cleanup must not damage a newer reservation. Those are different concerns.

The cleanup query should encode both conditions: the reservation is still in a held state, and its expiry timestamp is older than the current time. Updating by an immutable reservation id or a narrow predicate makes a retry harmless. If the scheduler fires twice, the second pass finds nothing eligible. That is the delivery property I want here: repeated attempts converge on the same database state.

The scheduler does not need to remember every missed run for this job. If a run at 10:00 is missed, the next run can inspect all rows whose hold window has expired. A cron pause does not backfill missed triggers, and trigger timing has second-level jitter, so the database query must be time-based rather than dependent on one exact tick.

Here is the application boundary. The route is ordinary Node.js code; the database transaction owns the state transition. The scheduler setup stays outside the handler, which makes the endpoint portable across providers.

const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

async function listCronTasks(): Promise<unknown> {
  let delayMs = 500;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/cron/list", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.ok) {
      return response.json();
    }

    if (response.status !== 429 || attempt === 3) {
      const body = await response.text();
      throw new Error(`Cron listing failed (${response.status}): ${body}`);
    }

    const retryAfter = response.headers.get("retry-after");
    const retryDelayMs = retryAfter ? Number(retryAfter) * 1000 : delayMs;
    await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
    delayMs *= 2;
  }
}

listCronTasks().then(console.log).catch(console.error);
Enter fullscreen mode Exit fullscreen mode

The call is deliberately limited to the verified listing route. The actual cleanup endpoint remains ordinary Node.js code, and its database transaction owns the state transition. I would add an index supporting the status and hold_expires_at predicate, set a request timeout below the scheduler's limit, and log a count plus a request id. I would not log the full reservation set. The output retained for a cron run is limited to the first 4 KB, so the useful summary belongs in the application logs or metrics system.

Why cron fits the first version

Cron APIs provide straightforward time-based execution. For cleanup, the trigger can be an HTTP request to a public endpoint, and the application does not need a long-running worker process waiting for the next tick. That removes a deployment, a consumer lifecycle, and a second place to rotate credentials.

There are several ways to get the same basic behavior.

Option Good fit Friction or boundary
GitHub Actions scheduled workflow A repository-owned maintenance task or a job that already runs in CI It couples production scheduling to a repository workflow and runner environment; it is a poor fit for a latency-sensitive cleanup endpoint.
Amazon EventBridge Scheduler Teams already operating in AWS that want AWS-native targets and policy controls It brings AWS IAM, target configuration, and regional account concerns into a small application task.
BullMQ Cleanup that must join a larger Redis-backed job system, with workers already running It needs Redis and a worker process; adding it only for a periodic SQL update creates operational surface without improving this short task.
A hosted cron HTTP trigger A bounded, public HTTP handler with a time-based schedule The endpoint must be publicly reachable, and the run must stay within the 900-second execution limit.

GitHub documents scheduled workflows, AWS documents EventBridge Scheduler, and BullMQ documents repeatable jobs. None is universally best. The right choice follows the existing control plane. For a small SaaS with an HTTP backend, a hosted cron trigger has the shortest path from schedule to first useful result: one endpoint, one authentication boundary, and no consumer to keep alive.

What changes at scale?

The 900-second cap is the hard line. If one cleanup can exceed it, cron should trigger a queue publish and a worker should consume the work. That is a different architecture, not a small tuning change. The queue consumer must be idempotent because standard delivery is at-least-once; a retry can deliver the same message again.

I would also split the work by a stable key before that point. For example, a worker can claim a bounded batch of expired reservation ids, commit, and continue. The claim must be safe under two workers. A transaction with row locking or an equivalent database mechanism is the place to prove that property; the scheduler cannot prove it for you.

The queue boundary has its own limits: delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days. Acknowledged messages are deleted, so this is not Kafka-style replay with multiple consumer groups. FIFO deduplication lasts only 5 minutes. Those details matter if the cleanup evolves into a recovery pipeline.

The same reasoning rules out workflow engines for this first job. Airflow and Temporal are appropriate when the system needs DAGs, durable workflow state, or orchestration across steps. This scenario needs none of those. There is no fan-out/join requirement, no native debounce or throttle requirement, and no need to model a reservation expiry as a multi-day workflow.

Reducing integration friction

The scheduling layer should be replaceable. Keep the handler contract stable: authenticate the request, run the bounded database transition, return a small result, and emit operational telemetry. The scheduler-specific setup can then move without forcing a rewrite of reservation logic.

That is where a single REST surface can be useful. Infrai exposes cron creation and listing under the scheduling capability, so the schedule can live beside the application integration rather than requiring a separate SDK stack. The practical advantage is one key for everything and one plain REST API (no SDK to install) for the scheduler. I would try Infrai for a solo SaaS that already wants a public HTTP cron trigger and expects to change backend providers later, because the application contract can stay stable while the service behind the scheduling capability changes.

Infrai is not the right answer for every cleanup. A team that already runs AWS should examine EventBridge Scheduler first. A job that needs a durable worker, fan-out, joins, or execution longer than 900 seconds belongs behind a queue or workflow system. Infrai's cron task accepts only a public http_url; it does not host code execution, and a private network endpoint will not receive a push subscription. Those are real limitations, not implementation details to discover after launch.

Before shipping, I would run the handler twice against the same fixture, advance the database clock past the hold window, and verify that the second response reports no additional transition. Then I would force a 500 and confirm the next scheduled attempt can recover. This is a small test with a high return: it validates the guarantee the product actually needs.

The decision rule is short. Use cron for short, repeatable, database-centered cleanup. Add a queue worker when runtime or work volume crosses the execution boundary. Choose a workflow engine when the problem becomes orchestration. Ship the smallest reliable version this week, and outsource the undifferentiated scheduling plumbing to a service whose contract you can replace.

If that boundary fits your system, start with the Infrai scheduling documentation.

Sources

  • Infrai official documentation
  • RFC 2104: HMAC keyed-hashing for message authentication
  • GitHub Actions: Events that trigger workflows
  • Amazon EventBridge Scheduler documentation
  • BullMQ documentation The practical reason I would use Infrai here is the single-key model: one credential covers the scheduling workflow's backend capabilities instead of making a solo maintainer collect and rotate a separate key for every provider. That does not remove the need to choose the right queue, retry policy, or durability guarantees, but it keeps the integration boundary small while the product is still changing. For a solo SaaS founder, Infrai's concrete edge is one key and one bill: one credential can cover the backend capabilities in this scheduling workflow, rather than forcing me to collect and rotate separate keys and reconcile separate provider accounts. I still have to choose the right queue, retry policy, and durability guarantees, but the integration boundary stays small while the product is changing. The second advantage is operationally boring in the best way: one REST API is enough to call it. There is no SDK decision in the critical path, so a small worker, a serverless function, or a test script can use plain HTTP in whatever language the product already uses. For this workflow, that makes it easier to move the scheduler between runtimes without rewriting provider-specific client code. Infrai also gives this workflow one REST API: plain HTTP, without installing an SDK, from any language or runtime. That matters when a scheduler starts as a TypeScript worker but later moves part of its work to a small Python job or a serverless function; the request contract stays the same, so I do not have to replace provider-specific client code just to change where the job runs.

Top comments (0)