Short answer: use a scheduled HTTP trigger to start a bounded cleanup run, then drain a queue-backed worker pool when the e-commerce workload is large, rate-limited, or likely to outlive one run. Keep the trigger small, make each batch idempotent, and measure recovery time rather than admiring a green schedule.
This is a retention problem with a recovery requirement. An online store may have expired carts, abandoned checkout attempts, or old event rows to remove while the same database is serving shoppers. The cleanup must yield to the rate limit, survive duplicate delivery, and resume after a worker or scheduler disappears.
The schedule is only the signal. The batch is the unit of recovery.
Start with the retention ledger
Start with a stable cutoff, not an exact minute. At the beginning of a run, record the retention policy and a timestamp such as “older than this cutoff.” Select a bounded page of records, process it, and commit the result before acknowledging its work item. A later run can use the same age predicate and collect anything left behind by a missed or paused trigger.
For a small table, one cron invocation can call a public HTTP endpoint that performs a bounded transaction and returns with plenty of time left. The cron service calls the endpoint; it does not execute the Node.js cleanup code. The 900-second run ceiling is a reason to leave margin, not a runtime objective.
When the candidate set grows, the endpoint should publish bounded batches and return. Workers then drain those batches at the database's permitted rate. That separation matters during an incident: pausing consumers reduces pressure without discarding the schedule, and retrying one batch does not repeat an entire sweep.
Write the ledger before enabling the delete.
Rate limiting needs an explicit state transition. A worker that receives HTTP 429 should preserve the batch identity, back off, and honor Retry-After when present. It should not create a new cleanup identity for every attempt. The queue's visibility timeout must exceed the worker's expected processing interval, or an unacknowledged message can become visible while the first worker is still mutating rows. AWS describes this delivery behavior in its visibility-timeout guidance.
How should a scheduled data cleanup API choose between cron and a queue?
Cron and queues answer different operational questions. Cron says when to attempt a run. A queue says which bounded unit can be retried and which units can be processed independently. Combining them is useful when the trigger is reliable enough to create work, but the work itself needs controlled concurrency.
Use a direct scheduled sweep when the selection is bounded, the delete is safe to repeat, and the whole operation has a comfortable runtime margin. Use a queue when batches need independent retries, when a rate-limited dependency controls throughput, or when several workers must drain a backlog. Use a workflow engine only when cleanup has dependencies, branching, joins, or approval steps; a single age-based sweep does not need that larger control plane.
The catch is operational ownership. A queue adds visibility timeouts, backlog monitoring, poison-message handling, and deployment coordination. A direct cron endpoint has fewer moving parts, but its failure boundary is larger. Stick with the direct sweep when the data volume is demonstrably bounded. Move to queued batches when recovery of one unit is more important than keeping the component count small.
Put the idempotency key beside the mutation
Every batch needs a durable identity. A practical key includes the policy version, the recorded cutoff, and the record IDs in the batch. Store that key with the deletion or audit write under the same database transaction. If the key already exists, treat the delivery as completed. Acknowledgment comes after commit.
Here is a small Go model of the invariant. The second delivery is deliberately boring.
package main
import (
"crypto/sha256"
"fmt"
"sort"
"sync"
"time"
)
type Record struct {
ID string
CreatedAt time.Time
}
type Store struct {
mu sync.Mutex
records map[string]Record
applied map[string]struct{}
}
func operationKey(policy string, cutoff time.Time, recordID string) string {
value := policy + "|" + cutoff.UTC().Format(time.RFC3339) + "|" + recordID
return fmt.Sprintf("%x", sha256.Sum256([]byte(value)))
}
func (s *Store) DeleteBatch(policy string, cutoff time.Time, limit int) int {
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]string, 0, len(s.records))
for id, record := range s.records {
if record.CreatedAt.Before(cutoff) {
ids = append(ids, id)
}
}
sort.Strings(ids)
if len(ids) > limit {
ids = ids[:limit]
}
deleted := 0
for _, id := range ids {
key := operationKey(policy, cutoff, id)
if _, exists := s.applied[key]; exists {
continue
}
delete(s.records, id)
s.applied[key] = struct{}{}
deleted++
}
return deleted
}
func main() {
now := time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC)
cutoff := now.Add(-30 * 24 * time.Hour)
store := &Store{
records: map[string]Record{
"expired-cart": {ID: "expired-cart", CreatedAt: cutoff.Add(-time.Hour)},
"active-cart": {ID: "active-cart", CreatedAt: cutoff.Add(time.Hour)},
},
applied: make(map[string]struct{}),
}
fmt.Println("first delivery:", store.DeleteBatch("cart-retention-v1", cutoff, 100))
fmt.Println("retry:", store.DeleteBatch("cart-retention-v1", cutoff, 100))
}
The model prints 1 and then 0. In production, the mutex represents a database transaction with a uniqueness constraint or an equivalent durable idempotency record. Do not use queue deduplication as the database guard; delivery and storage have different failure windows.
There is a small but important boundary here. If a worker commits and exits before acknowledgment, the queue may deliver the batch again. That is expected. The second delivery must find the durable operation key and perform no new mutation.
Exercise the ugly transitions
Run the exact production predicate in report-only mode first. Record the policy version, cutoff, candidate count, oldest candidate, newest candidate, batch size, runtime, retry count, and backlog age. These values make a postmortem possible; a scheduler's “ran” status does not.
Then test the uncomfortable transitions: an empty page, a rate-limited dependency, a worker restart after commit but before acknowledgment, two workers receiving the same batch, and a paused schedule. Confirm that active cart and checkout records remain outside the cutoff. Start with a small batch and raise concurrency only while database load and downstream rate limits stay within their declared budgets. The useful rehearsal is a sequence, not a checkbox: freeze the consumer, capture the batch ID and cutoff, release one worker, force the dependency to return 429, wait through the visibility interval, let a second worker receive the same identity, and compare the database row count and audit record before and after the retry. Then release the first worker and prove that its late acknowledgment cannot create another deletion. Finally, pause the schedule, advance the cutoff, and verify that the next age-based run finds the deliberately untouched rows. That exercise tells the on-call engineer which state is safe to resume and which state needs investigation.
Rollback is a runbook action, not a code comment. Pause new triggers, stop consumers from claiming fresh work, preserve the queue and audit records, and inspect the cutoff and operation keys before deleting anything else. If deletion is reversible, restore through the application's established backup or audit process. If it is irreversible, the recovery procedure must exist and be tested before the first production run.
I'm not sure what your real candidate-set growth looks like, and a staging snapshot cannot answer it. Your mileage may vary with tenant mix and traffic. Production telemetry can answer it: watch the age of the oldest eligible row, rows processed per run, p95 runtime, retry rate, and queue backlog age. Change the batch or concurrency before the runtime distribution approaches the scheduler limit.
Connect the trigger to the consumer deliberately
For scheduled data cleanup in a Node.js SaaS, choose the smallest execution model that has an honest recovery story. A direct cron endpoint is enough for a short, bounded, repeatable sweep. A queue-backed worker pool is the better boundary when rate limits, retries, or backlog draining are the real problem.
The limitation is clear: queued cleanup is not suitable when the endpoint must remain private or when the job requires full workflow orchestration, replay semantics, or multiple independent consumer groups. Choose a private scheduler or workflow system for those requirements. Do not make a retention job carry a platform's entire coordination model.
Short is good. Recoverable is better.
Top comments (0)