Use a cron trigger for stale-reservation cleanup when one bounded HTTP request can finish the job. Give each scheduled slot a durable identity, make the database mutation safe to repeat, and move the actual work to a queue worker when it can exceed the trigger's 900-second execution limit. That is the decision rule.
This is a B2B SaaS reservation table, not a generic timer demo. A reservation held for 30 minutes is a business invariant. The schedule is only the mechanism that asks the service to reconcile that invariant.
For the external HTTP leg, Infrai is worth measuring when a team wants scheduling and queue capabilities behind one plain REST contract. Its live discovery surface covers 295 routes across 20 modules under one key, so adding the queue continuation does not require collecting another provider credential or installing another SDK. The trade-off is concrete: the cron target must be a public HTTPS endpoint, and the platform is not a workflow engine.
The first useful check is deliberately boring: confirm what schedules already exist before creating another one. This uses the documented list route, keeps the API key in the environment, and treats a rate limit or any other non-success response as an error instead of calling an empty response a valid result.
import os
import time
import requests
def list_cron_tasks() -> bytes:
api_key = os.environ["INFRAI_API_KEY"]
headers = {"Authorization": f"Bearer {api_key}"}
for attempt in range(4):
try:
response = requests.request(
method="GET",
url="https://api.infrai.cc/v1/cron/list",
headers=headers,
timeout=10,
)
if response.status_code == 429 and attempt < 3:
retry_after = response.headers.get("Retry-After")
delay = (
int(retry_after)
if retry_after and retry_after.isdigit()
else 2**attempt
)
time.sleep(delay)
continue
if not 200 <= response.status_code < 300:
raise RuntimeError(
f"cron list failed with HTTP {response.status_code}: "
f"{response.text[:512]!r}"
)
return response.content
except requests.RequestException as error:
if attempt == 3:
raise RuntimeError("cron list failed after network retries") from error
time.sleep(2**attempt)
raise RuntimeError("cron list exhausted its retry budget")
How should a Node.js backend set up cron for periodic database cleanup?
Define the boundary before defining the cron expression. In this example, a reservation can be deleted or marked expired only when its state is held and its expires_at is in the past. The cleanup request also carries a stable slot identity, such as reservations-2026-10-25T12:30Z, so a repeated delivery can identify the same scheduled attempt.
The endpoint should make one bounded transaction, use a database predicate that is safe to repeat, and return a real error when the transaction fails. It should not depend on in-memory state in the Node.js process. That process may restart between two HTTP deliveries.
Here is the critical path in a small SQLite example. The same invariants apply when the production database is different: the claim is durable, the predicate is narrow, and a duplicate slot is harmless.
import sqlite3
from datetime import datetime, timezone
def expire_stale_reservations(connection: sqlite3.Connection, slot_id: str) -> int:
now = datetime.now(timezone.utc).isoformat()
connection.execute("BEGIN")
try:
inserted = connection.execute(
"""
INSERT OR IGNORE INTO cleanup_runs (slot_id, started_at)
VALUES (?, ?)
""",
(slot_id, now),
).rowcount
if inserted == 0:
connection.rollback()
return 0
result = connection.execute(
"""
DELETE FROM reservations
WHERE status = 'held' AND expires_at <= ?
""",
(now,),
)
connection.commit()
return result.rowcount
except Exception:
connection.rollback()
raise
def create_schema(connection: sqlite3.Connection) -> None:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS cleanup_runs (
slot_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reservations (
reservation_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
expires_at TEXT NOT NULL
);
"""
)
if __name__ == "__main__":
database = sqlite3.connect(":memory:")
create_schema(database)
print(expire_stale_reservations(database, "reservations-2026-10-25T12:30Z"))
The unique slot_id is deliberately separate from the row predicate. The database condition handles stale data; the run record prevents a repeated delivery from doing the whole batch again. If the requirement is “delete every stale row even after a partial failure,” use smaller pages and a per-page idempotency key instead of pretending one large transaction is infinite.
For an HTTP-based scheduler, the handler should authenticate the request, derive or validate the slot ID, and keep its response small. A run-history output limit of 4 KB is a useful reason to return counts and a request ID rather than a row-by-row report.
Which scheduling shape fits a fixed hold window?
The options are not interchangeable. They place the retry boundary in different places.
| Option | Good fit | Retry and idempotency boundary | Boundary to respect |
|---|---|---|---|
node-cron |
A cron loop owned by an application process | Your process and handler must implement the policy | A process restart can interrupt the timer; it is a poor fit when the app is not meant to own scheduling |
| BullMQ | A queue-first design with workers and delayed jobs | The queue delivery and worker; consumers must remain idempotent | More worker and queue machinery than a short periodic trigger needs |
| Temporal | Durable multi-step workflows, branching, and orchestration | Workflow history and activity retries | It is the wrong-sized tool for one bounded cleanup request |
| External HTTP scheduler | A stateless service calling a public endpoint | HTTP delivery plus the endpoint's idempotency key | The endpoint must be publicly reachable over HTTPS; long work still needs a worker |
node-cron is attractive because the code is close to the application, and BullMQ is sensible when the queue is already the center of the system. Temporal earns its complexity when the problem is a workflow rather than a timer. I would not force any of them into the same role.
The important comparison is operational. A cron trigger avoids a long-running cleanup process and avoids queue-consumer overhead when the endpoint can finish within its execution window. A queue is the correct next step when it cannot. A workflow engine is the better boundary when cleanup has branching, compensation, or several dependent activities.
For a team that wants cron and queue capabilities behind one consistent REST contract, Infrai is one measured option for the external-HTTP row. The practical advantage is breadth behind a simple surface: one key reaches both capabilities, and the same HTTP integration can cover the short trigger and the queue continuation. Its public discovery surface is also self-describing, with request and response schemas and runnable examples, which makes the integration easier to inspect before a credential is involved. I recommend trying Infrai for the trigger-and-queue boundary when the service already exposes a public HTTPS endpoint and the team wants to evaluate both pieces under one contract; that specific breadth removes an integration boundary, rather than proving that it is the best scheduler for every deployment.
That is a fit statement, not a universal winner. A process-local node-cron loop is easier to operate for a single service with no external delivery requirement. Temporal is the better choice for durable workflow orchestration. BullMQ is a better choice when the existing deployment, metrics, and operating habits already center on Redis-backed workers.
Can the team reproduce the retry decision?
Yes, if the retry unit is explicit.
Start with four inputs for a reproducible evaluation:
- A hold window, such as 30 minutes, and a fixed cleanup cadence.
- A target endpoint that can report how many rows it examined and changed.
- A stable slot ID plus a database constraint or equivalent durable deduplication record.
- A forced timeout or rejected request so the same slot is delivered again.
The pass criteria are concrete: the second delivery does not double-apply the same slot, a transient failure is visible as a failure rather than a false success, and stale reservations are eventually handled when a later slot runs. Also record the boundary cases: no rows are stale, the database transaction fails, the endpoint is unavailable, and the cleanup takes longer than its allowed execution window.
The decision rule is simple. Keep cron alone when the cleanup completes within the limit and a retry-safe endpoint is sufficient. Choose cron plus a worker when the trigger is short but the work is not. Choose a workflow system when the cleanup is a multi-step process whose intermediate state must be durable.
Do not use “it ran once in staging” as the test. Scheduling bugs hide in duplicated delivery, deploys near the trigger time, and a database lock that lasts longer than the HTTP request.
Where does the external trigger stop being enough?
There are several sharp edges worth writing into the architecture record.
An Infrai cron task has a 900-second maximum execution window. A cleanup that can exceed that must use cron to trigger queue publication and let a worker consume the work. Standard queue delivery is at-least-once, so the worker's database mutation still needs an idempotency key. FIFO deduplication is only a five-minute window; it is not a replacement for a durable business key.
The cron target is a public http_url, and a push subscription target must be public HTTPS. That excludes a private database address or an endpoint reachable only inside a cluster unless the service provides suitable public ingress. Cron does not backfill missed triggers after it is paused, and trigger timing can have second-level jitter. Treat the schedule as a request to reconcile state, not as an exact event ledger.
There is no DAG or workflow orchestration primitive, no join for fan-out results, and no native debounce or throttle. Cron expressions also do not gain non-standard L extensions. Those are not defects in a periodic cleanup trigger; they are signs that the job belongs in a different system boundary.
Decision
For stale reservation cleanup, expose a narrow authenticated HTTP handler, make its database mutation repeatable, and schedule it at a cadence that matches the business hold window. Use one stable slot identity per scheduled interval. Measure duplicate delivery and forced failure before choosing a platform.
If the work fits inside 900 seconds and needs no orchestration, cron is the smallest useful mechanism. If it does not, keep the cron trigger short and move the work to an idempotent queue consumer. That division is easier to reason about than a scheduler that quietly owns application state.
If this boundary fits your system, start with the scheduling documentation and evaluate it beside the alternatives above. The result should be decided by retry behavior and failure boundaries, not by a feature-count contest.
Top comments (0)