Short answer: use a Node.js cron setup that claims due database cleanup rows with a lease; use a durable queue only when retry backlog and replay controls justify the extra state machine.

A cleanup job in a logistics backend should be boring: claim old rows, make one bounded attempt, record the result, and leave the next run enough information to recover. For outbound shipment webhooks, that means a retry must never turn one delivery into two customer-visible state changes.

The least complex setup is a single Node.js worker process triggered by a scheduler, with the database as the source of truth for claims and idempotency. A cron expression alone cannot provide delivery semantics. It only provides a chance to run code.

That is the boundary. Scheduling is not delivery.

Pattern Best fit Main risk
Scheduler + database lease One worker or a small service Lease expiry during a slow attempt
Scheduler + durable queue Several consumers or long backlogs More moving parts and a second state machine

Choose the lease when cleanup is short and the table is the queue. Choose the durable queue when retry volume, backoff, or worker concurrency is the real problem. In both cases, make the webhook event idempotent before tuning timing.

No magic.

What should the cleanup job actually guarantee?

Start with a contract, not a cron line. For each shipment event, store an immutable event ID, the destination, the payload hash, the attempt count, the next attempt time, and a terminal status. The unique key should cover the event ID and destination. A delivery record is then a fact that can be claimed; it is not a transient JavaScript object.

A worker can claim rows with a short lease. The claim must be conditional, so two workers cannot both believe they own the same row. FOR UPDATE SKIP LOCKED is useful for work tables because locked rows do not make other workers wait, but it does not make an HTTP request transactional with the database. The network call happens outside the database transaction. That boundary is where duplicate delivery appears.

Here is the shape I use for the decision, with generic SQL access hidden behind an interface so the scheduling choice stays testable:

type Delivery = { id: string; eventId: string; destination: string; payload: string; attempt: number; leaseUntil: Date | null; }; type DeliveryStore = { claim(limit: number, now: Date, leaseMs: number): Promise<Delivery[]>; markDelivered(id: string, now: Date): Promise<void>; reschedule(id: string, nextAttempt: Date, reason: string): Promise<void>; markDead(id: string, reason: string): Promise<void>; }; async function runCleanup(store: DeliveryStore, now = new Date()): Promise<void> { const rows = await store.claim(50, now, 60_000); for (const row of rows) { try { const response = await fetch(row.destination, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": row.eventId }, body: row.payload }); if (response.ok) { await store.markDelivered(row.id, new Date()); } else if (response.status >= 400 && response.status < 500) { await store.markDead(row.id, `permanent status ${response.status}`); } else { await retryOrStop(store, row); } } catch { await retryOrStop(store, row); } } } async function retryOrStop(store: DeliveryStore, row: Delivery): Promise<void> { if (row.attempt >= 8) { await store.markDead(row.id, "attempt limit reached"); return; } const delayMs = Math.min(900_000, 2 ** row.attempt * 1_000); await store.reschedule(row.id, new Date(Date.now() + delayMs), "transient failure"); } 

The number 50 is a starting batch limit, not a law. Measure lock time, request latency, and the age of the oldest pending delivery before changing it. A larger batch can improve database efficiency while making a process restart leave more leased work behind. I prefer the smaller batch until the queue's shape proves otherwise.

This is a deliberate trade-off: fewer moving parts means fewer operational controls. If operators need replay, pause, or per-destination backoff, the lease pattern is the wrong fit.

Where does duplicate delivery still happen?

Suppose the destination returns 200, then the worker dies before markDelivered. The next run sends the event again. No retry algorithm can remove that crash window when the destination and database do not share a transaction. The correct response is an idempotency contract at the receiver: the receiver stores the event ID before applying the state transition, and repeats return the original result.

Do not use a timestamp as the idempotency key. Clock values collide at useful volumes and say nothing about the event. Do not hash an arbitrary serialization unless its canonical form is stable. If the receiver needs authenticity, sign the canonical request with an HMAC and compare the signature in constant time; RFC 2104 defines the keyed-hashing construction and its authentication model. The signature authenticates the message. It does not make delivery exactly once.

A cleanup task also needs a retention rule. Keep successful delivery records long enough to cover the receiver's duplicate-detection window. Delete them in a separate bounded job. Deleting the record immediately after a successful request erases the only evidence that a later duplicate is a duplicate.

Is cron enough for a Node.js backend?

Cron is enough when one scheduled invocation can safely overlap with another. That requires an atomic claim, a lease timeout, and a process-level timeout shorter than the lease. A scheduler that starts every minute does not guarantee that the previous run finished; assume overlap.

For a small service, the runner can be a Node.js process with one timer and a graceful shutdown handler. The timer is only a wake-up mechanism. On startup, the worker should reclaim expired leases, then process due rows. On shutdown, stop claiming new rows and let in-flight requests finish within a deadline. This gives the database a chance to distinguish abandoned work from work still owned by a live process.

A durable queue is the better runner when many destinations have different retry policies, when backoff creates a real backlog, or when operators need pause and replay controls. The database record should remain authoritative for delivery state; otherwise the queue and database can disagree about whether a shipment event was sent. That extra coordination is the price of operational control.

The limitation is important: neither runner can make an external HTTP call and a database update one atomic transaction.

The operating checks that matter

Test the ugly transitions directly: a timeout after the receiver applied the event, a worker crash after a 200 response, two workers claiming the same row, a lease expiring during a slow request, and a permanent 4xx response. A happy-path test does not exercise the duplicate window.

Expose counts for due, leased, delivered, dead-lettered, and expired rows. Track the age of the oldest due row and the distribution of attempts. Alert on lease expiry and growing age, not only on process health. A green process with a stuck table is still a failed cleanup system.

The runner-up architecture, a durable queue, wins when replay and backoff are first-class operations. The scheduler-plus-database lease wins when the workload is modest and the team values fewer state stores. Neither choice fixes a receiver that applies the same event twice. Put the idempotency key and receiver-side uniqueness constraint in the integration contract, then let the scheduler stay deliberately dull.

Further reading