Short answer: schedule the renewal cleanup as a durable, idempotent job, and choose the smallest queue that can prove recovery after a worker, database, or region failure. For a small SaaS serving EU and US shops, PostgreSQL plus a short polling worker is usually easier to operate than a second broker; move to BullMQ, RabbitMQ, or a hosted queue when the recovery evidence says you need their specific delivery controls.
The job is concrete: a merchant sets a business deadline, and the system delays a renewal reminder until that deadline. The cleanup pass removes expired reminder intents and releases the ones whose deadline has arrived. “Run it every night” is not a recovery plan. A deployment at 23:59 UTC, a DST change in Berlin, or a US database failover can make that sentence surprisingly expensive.
Which scheduled data cleanup failures must a small SaaS recover?
The key record is not a timer. It is a row with an immutable run_at, a tenant or shop identifier, an idempotency key, and a state transition. Keep the business deadline in UTC after validating the merchant's declared time zone. Store the original zone for audit; do not recalculate old deadlines when a time-zone rule changes.
I start with a failure question: what happens if a worker dies after claiming a reminder but before sending it? The answer should be “another worker can reclaim it after a lease expires, and the send operation is safe to repeat.” That requirement points to a database-backed work queue for a small installation. It also makes a hosted queue less magical: you still own idempotency, visibility timeouts, and the outbox boundary.
Put the deadline and lease in PostgreSQL
Use a narrow poller. It claims a bounded batch with row locks, commits the claim, performs the side effect, then records success. PostgreSQL documents FOR UPDATE SKIP LOCKED as suitable for avoiding lock contention in queue-like tables. The query below is the smallest useful shape; the message sender is deliberately an interface so it can be replaced or tested without a broker.
type CleanupJob = {
id: string;
shopId: string;
idempotencyKey: string;
};
async function claimDueJobs(db: { query: Function }, limit: number): Promise<CleanupJob[]> {
const result = await db.query(
`UPDATE cleanup_jobs
SET state = 'leased', lease_until = now() + interval '5 minutes', attempts = attempts + 1
WHERE id IN (
SELECT id FROM cleanup_jobs
WHERE state = 'ready' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT $1
)
RETURNING id, shop_id AS "shopId", idempotency_key AS "idempotencyKey"`,
[limit],
);
return result.rows;
}
The sender must treat idempotencyKey as a durable business key. A timeout is unknown, not a failure that proves no reminder was sent. Record the attempt, response class, and next retry time. Keep a dead-letter state for records that exceed a bounded attempt count, then expose that state to support staff.
One sentence matters here.
Test the broker boundary with a failure drill
They change operational responsibility more than they change the deadline algorithm. BullMQ gives a Redis-backed job model and familiar delayed-job ergonomics, but the team now runs Redis and watches its persistence and memory behavior. RabbitMQ offers explicit acknowledgements, routing, and dead-letter exchanges; that is useful when several consumers need different delivery paths, yet it adds broker topology and upgrade work. A hosted queue removes much of that server maintenance, while introducing a vendor-specific visibility, retention, region, and export contract.
The comparison I use is recovery evidence, not a feature checklist:
| Option | Failure it handles well | Cost in attention | Boundary |
|---|---|---|---|
| PostgreSQL poller | Worker restarts and transactional claiming | Low for one database | Throughput and schedule precision are limited by the database |
| BullMQ | Delays, retries, and worker concurrency | Redis operations become your job | Redis durability and regional recovery need testing |
| RabbitMQ | Routing and explicit acknowledgements | Broker topology and upgrades | Delayed delivery needs deliberate queue design |
| Hosted queue | Managed broker availability | Contract, data residency, and egress review | Recovery depends on the provider's controls |
The catch is that none of these proves a reminder was delivered. If auditability across EU and US tenants is the hard requirement, keep an application-owned attempt ledger even when a broker is managed. Stick with the database poller when one region, one consumer, and a five-minute recovery window are acceptable. Pick a broker when you can name the load or routing boundary it removes.
Connect the outbox to the effect worker
Ship the poller behind a feature flag. First, insert jobs through the same transaction that commits a renewal change (an outbox row is enough). Second, run two workers in a staging database and kill one between claim and send. Third, advance the clock through a DST boundary and verify that run_at stays stable. Finally, replay the same idempotency key and assert one external effect.
Measure claim age, lease-expiry count, retry age, dead-letter count, and rows waiting by shop. Alert on age, not just process health. A worker can be alive while every job is stuck behind a bad lease. Keep payloads small and avoid putting customer data in broker metadata; EU-US routing and retention are part of the design review, not a later compliance patch.
At scale, I would split the scheduler from the effect worker, shard by shop, and move the attempt ledger to an append-only stream. Your mileage may vary: a low-volume SaaS may never need that split, and a regulated merchant may need it on day one. The decision should follow replay tests and recovery objectives, not a queue's marketing label.
Top comments (0)