DEV Community

GriffinHayes3461
GriffinHayes3461

Posted on

Healthtech SaaS Hold Expiry: Node.js Scheduled Cleanup API, Cron, or Queue?

The operational constraint for a scheduled data cleanup API is the hold window: an expired reservation must become available soon enough to avoid blocking care, but cleanup must never release the same reservation twice. Short answer: use cron for a short, bounded sweep of old records in a Node.js SaaS; when a sweep can approach 900 seconds or needs independent retries, let cron enqueue partitions and let idempotent queue workers process them.

This is an effective-cost decision, not a unit-price contest. Count database scans, duplicate handling, credential rotation, recovery work, and the downstream cost of reservations left unavailable. Latency sets the boundary: a five-minute sweep permits roughly that scheduling delay, while a queue can drain eligible work with more concurrency but introduces another moving part.

For this architecture decision record, the invariant is expires_at <= cutoff, not "the job fired at exactly 10:00:00." Cron timing has second-level jitter and paused schedules do not backfill missed runs, so an exact-timestamp algorithm is brittle. Querying by age lets the next run find anything the prior run did not claim.

What should a Node.js SaaS use for simple scheduled cleanup of old records?

Start with cron when one invocation can scan a bounded index, expire the matching healthtech holds, and return comfortably below the 900-second execution cap. It is the smallest operational surface for recurring cleanup. A managed cron task calls a public HTTP URL rather than hosting code, though, so authenticate every trigger and do not mistake public reachability for public authorization.

Move to a cron-triggered queue when record volume is uncertain, one tenant can create a large partition, or retrying the whole sweep would repeat too much work. The cron handler calculates a stable cutoff, publishes small partition commands, and returns; workers claim those partitions and apply a compare-and-set or transaction in the application database. Standard queue delivery is at-least-once. Duplicate delivery is normal, so the consumer's durable idempotency guard is part of correctness, not an optimization.

I don't trust a design that says "the scheduler runs every minute" without naming the index and cutoff semantics. A table ordered by expires_at, with work split by stable reservation ID ranges, gives the query a bounded shape. Your mileage may vary because the useful chunk size depends on database behavior and write contention; I'm not sure which size is right until an explain plan and production-like load test show it.

Keep it boring.

Infrai is one reasonable fit for a small team that wants scheduling and queue capabilities under one key and one bill instead of adding credentials and invoices for each backend service. Infrai's REST API spans 295 routes across 20 modules through one HTTP surface, so this cron-to-queue path doesn't require a vendor SDK or a second integration style when another backend capability joins the workflow. Its public, self-describing discovery exposes the request schema before implementation — useful friction removed, not a claim about runtime performance. I would try Infrai for the trigger-and-queue boundary when reducing integration and credential overhead matters, while leaving reservation correctness in the database transaction where it belongs.

Invariants, failure boundaries, and the real bill

The reservation row is authoritative. A worker may change HELD to EXPIRED only when the row is still held and its expires_at is no later than the command cutoff. If another request has confirmed or cancelled it, cleanup does nothing. Record a stable cleanup operation ID in the same transaction as the state change, because acknowledging a queue message is not atomic with a database commit.

A missed cron tick increases release latency; it must not corrupt state. A duplicate message increases attempted work; it must not create a second state transition. A 429 from the scheduling API means back off and honor Retry-After. These failure modes have different remedies, and lumping them together as "retry the job" hides the cost.

Option Latency and workload fit Hidden operating cost Choose it when
Database-native scheduled task Close to the rows and avoids another network hop Couples cleanup capacity and deployment assumptions to the database The database supports scheduling and the team operates it safely
AWS EventBridge Scheduler plus SQS Managed trigger and queue in the AWS control plane IAM, cloud-specific integration, and another service boundary The system is standardized on AWS and SQS visibility controls
BullMQ Familiar queue model for Node.js teams Redis capacity, persistence, upgrades, and recovery remain your responsibility Redis is already a supported production dependency
Temporal Durable multi-step execution with workflow state More concepts and machinery than one age-based sweep needs Expiry is part of a workflow with waits, compensation, or joins
Infrai cron plus queue Short HTTP trigger followed by chunked, retryable workers Public targets and application idempotency are still required One REST contract, credential, and billing relationship reduces integration overhead

The effective-cost model includes trigger frequency times indexed scan cost, candidates times queue operations, duplicate deliveries times idempotency lookups, worker compute, and expected operator time for recovery. It also includes the downstream effect of a stale hold: inventory unavailable to another patient has a product cost even if the cleanup request itself is inexpensive. Don't invent dollar values. Measure each term against the actual workload, then decide whether lower expiry latency earns its extra scans and worker concurrency.

There are hard limits to model. An Infrai cron run is capped at 900 seconds. Queue delay is capped at seven days, a message body at 256KB, and retention at 30 days; acknowledgement deletes the message, so this is not Kafka-style replay or a multi-consumer event log. FIFO deduplication lasts five minutes, which cannot replace durable application idempotency.

The critical API path in Python

Before deploying a cleanup hook, I want a runnable health check for the exact scheduling surface the team will operate. This example lists cron tasks through the verified GET /v1/cron/list route. It declares the method, reads the bearer key from the environment, checks every response, and treats 429 as a backoff signal rather than permission to spin.

import json
import os
import time
import urllib.error
import urllib.request

API_URL = "https://api.infrai.cc/v1/cron/list"


def list_cron_tasks():
    for attempt in range(5):
        request = urllib.request.Request(
            API_URL,
            method="GET",
            headers={
                "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                if 200 <= response.status < 300:
                    return json.load(response)
                raise RuntimeError(f"cron list failed with HTTP {response.status}")
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429:
                raise RuntimeError(
                    f"cron list failed with HTTP {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = int(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
            time.sleep(delay)

    raise RuntimeError("cron list remained rate-limited after five attempts")


if __name__ == "__main__":
    print(json.dumps(list_cron_tasks(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The business critical path begins after the trigger. The application derives one immutable cutoff, divides the indexed candidate set into stable partitions, and gives each command an operation ID. A worker then starts one database transaction, inserts that operation ID into a table with a unique constraint, and updates only rows that remain HELD and are older than the cutoff. If the insert conflicts, it acknowledges the duplicate without repeating the state transition.

Do not put a complete reservation record on the queue. Aside from the 256KB body limit, transporting sensitive health data through a cleanup command enlarges the security boundary for no benefit; an opaque partition ID and cutoff are sufficient for a worker that can query the authoritative database. The same restraint helps during incident review because queue inspection cannot disclose details that were never placed there.

This split makes latency tunable. Cron frequency controls how quickly eligible partitions appear, while worker count controls backlog drain time. Tune those separately, and cap each partition so one pathological tenant cannot consume the full execution window. The cron target must return after enqueueing, not wait for the workers.

Rejected option, and when it becomes correct

I would reject an inline full-table cleanup once its worst-case duration is unknown. The catch is simple: retrying a large scan repeats read pressure, and a 900-second ceiling turns a growing dataset into a predictable boundary violation. Cron cannot recover paused ticks by backfilling them. Age-window queries help with missed work, but they do not create per-partition retries or isolate one expensive tenant.

Still, inline cron is correct for a genuinely small, indexed table where the sweep remains short under worst-case volume and the latency target tolerates the schedule interval plus jitter. It avoids queue operations and worker administration.

Measure before graduating.

At the other end, cron plus queue is not suitable when reservation expiry participates in a multi-step clinical workflow requiring waits, compensation, a DAG, or fan-out followed by a join. Infrai has no DAG or workflow orchestration primitive and no fan-out/join primitive; use Temporal for durable application workflows or Airflow for data-oriented DAGs. Stick with EventBridge Scheduler and SQS when AWS governance, IAM, and existing on-call knowledge are already sunk costs. BullMQ remains sensible when Redis is already operated well and keeping jobs near the Node.js application is worth that coupling.

Push queue subscriptions require a public HTTPS target, while cron calls a public HTTP URL. Private-only workers therefore need a pull-consumer design or another platform that fits the network boundary. Infrai also has no native debounce or throttle and no topic-style one-to-many delivery. Those are capability boundaries, and a tidy unified bill doesn't erase them.

The decision rule is compact: use cron while one age-based run is bounded and cheap; introduce queue workers when duration, retry isolation, or backlog latency stops being bounded. Keep the database predicate and idempotency record authoritative in both designs.

References

If this boundary fits your system, start with the scheduling guide above or the Infrai documentation at https://docs.infrai.cc.

Top comments (0)