Short answer: use a scheduled cleanup endpoint when one indexed, bounded pass can finish predictably; use a queue when cleanup must be divided into independently retriable batches.
For a fintech SaaS that fans out shipment updates to many subscribers, latency and cost should be judged at the system boundary: a cheap cleanup run is not a good bargain if it contends with delivery or leaves retention evidence incomplete.
The first design decision is to keep shipment fan-out separate from retention work.
A shipment update has a latency-sensitive path.
Expired subscriptions, old delivery attempts, and temporary fan-out records usually have a policy-driven path.
They may share a database, but they should not share an unbounded transaction or an execution budget.
This distinction matters more than the spelling of a cron expression.
It also gives the team a useful test: can the cleanup be repeated safely while the shipment update path continues to make progress?
How should a Node.js SaaS choose a cron or queue for scheduled cleanup?
Measure the worst case first.
Count eligible records by tenant, check the relevant index, estimate lock pressure, and measure a bounded pass while the database is serving normal shipment traffic.
The median duration is not the decision variable; the tail is.
A scheduled data cleanup is a good fit for one HTTP-triggered run when its cutoff, tenant scope, batch size, and completion state can be recorded and the run has room to finish before its execution limit.
The cutoff should be computed by the application and persisted with the run.
A schedule has jitter, and a paused schedule may not replay every missed invocation. “Delete records older than the cutoff captured at run start” is therefore more auditable than silently recalculating the boundary for every page.
The query should also exclude legal holds, active disputes, and any retention exception required by the business policy.
Keep it bounded.
The boundary is operational.
When a tenant can monopolize a scan, when the pessimistic duration approaches the execution limit, or when one failed slice should not restart the entire pass, let the scheduled trigger produce work for queue consumers.
Cron still supplies the clock; workers supply the execution boundary.
A process-local Node.js timer does neither reliably in a multi-replica deployment, because each replica can make its own decision about when to run.
Consider a cleanup run with tenant-17 as the largest scope.
The producer records one cutoff and emits a sequence of batch identities, each representing a bounded ordinal rather than a mutable list of row IDs.
A worker claims the next slice using the eligibility predicate and a limit, records the claim, and commits the expiration transition with its completion marker.
A retry does not need to know whether the first attempt reached the database, lost its connection after commit, or was made visible again after its timeout; it checks the marker and the conditional state, then reports a duplicate completion as a normal outcome.
Meanwhile, a separate delivery worker can continue to process shipment updates because cleanup concurrency is capped and because the cleanup query uses the intended index rather than scanning the delivery history without a boundary.
If the tenant has more eligible records than one run can handle, the next scheduled run continues from recorded state or creates the next immutable batch set, depending on the storage model.
Neither choice should silently move the cutoff, because doing so makes the audit trail ambiguous: an operator could no longer tell whether a record was outside policy at the first run or merely missed by a later page.
This is the kind of detail that makes a queue worthwhile, but it is also the detail that makes a queue expensive to operate.
Why do old records need idempotency and an audit trail?
Deletion is an irreversible business effect, even if the database operation itself is ordinary.
Exactly-once transport is not