Short answer: choose a database timer when cleanup is one bounded, transactional delete; choose an app cron webhook when the job must fan out shipment updates, retry subscribers independently, and expose delivery state.
For a gaming marketplace, “delete old shipment rows every night” sounds like a database problem. It stops being one as soon as the cleanup must notify storefronts, guild tools, and player apps before those rows disappear. The scheduler is no longer the hard part. Retry ownership is.
My default for that second shape is the app layer — but only because it can give every subscriber an idempotency key and a separate delivery record. For a small retention sweep with no external effect, keeping the timer beside the data is simpler and usually easier to operate.
The before-and-after mental model
Before fan-out, the diagram in words is tiny: clock → database transaction → deleted rows. A PostgreSQL job such as pg_cron can fit that boundary. The transaction either commits or rolls back, and the operational question is mostly whether the run happened and how many records it changed.
After fan-out, draw a different picture: clock → cleanup run → immutable shipment events → one delivery per subscriber → retry queue → subscriber. The cleanup run can finish while deliveries remain pending. That separation matters because subscriber A may acknowledge immediately, subscriber B may throttle with 429, and subscriber C may time out after accepting the message. Retrying the entire cleanup would duplicate work for A and risk losing the ambiguous delivery to C.
That's the pivot.
The database timer and the app cron webhook can both wake work up. Only the surrounding design decides whether a retry means “repeat the whole run” or “resume one unacknowledged delivery.” A scheduler dashboard that says “success” is weak evidence if 18 of 2,000 subscriber deliveries are still pending. Track the run and its children separately.
How should scheduled data cleanup choose between app cron and a webhook scheduler?
Use retry scope as the first filter, then check transaction scope and observability. The cheapest scheduler on a pricing page isn't necessarily the lowest-cost system to own; a mechanism that forces an operator to reconstruct partial fan-out from logs can cost more engineering time than the trigger itself. I wouldn't use price as the lead criterion here.
| Condition | Database timer | App cron webhook |
|---|---|---|
| Work is one SQL transaction with no external side effect | Prefer it | Extra moving parts |
| Each subscriber needs an independent retry | Poor boundary for network delivery | Prefer it |
| Cleanup must commit before notifications begin | Requires a durable handoff design | Natural place to orchestrate the handoff |
| Team can operate only the database | Prefer it | Adds an application runtime |
| Delivery latency, attempts, and backlog need separate alerts | Needs more application plumbing | Prefer it |
Three conditions make me choose the app boundary: one run creates many deliveries, receivers can throttle independently, or operators need to replay a single receiver without rerunning deletion. If none applies, a database-local job stays attractive.
Simple wins.
The catch is that an app scheduler is not suitable when the application runtime is routinely scaled to zero without an external wake-up mechanism, or when the cleanup must be inseparable from a single database transaction. Stick with the database timer for that narrow shape. Conversely, don't put outbound HTTP calls inside a long cleanup transaction: network latency stretches lock time and makes the commit depend on systems outside the database.
A copyable idempotent fan-out shape
The handler below assumes the clock can invoke the same run more than once. That is a healthy assumption for any retrying system. A stable runId identifies the cleanup window; a stable delivery key identifies one shipment event sent to one subscriber. Database uniqueness claims those identifiers before side effects occur.
import { createHmac, timingSafeEqual } from "node:crypto"; type Tick = { runId: string; cutoffIso: string; signature: string; }; type ShipmentEvent = { eventId: string; shipmentId: string; status: "expired"; }; type Subscriber = { subscriberId: string; }; interface CleanupStore { claimRun(runId: string, cutoffIso: string): Promise<boolean>; expireShipments(cutoffIso: string): Promise<ShipmentEvent[]>; listSubscribers(event: ShipmentEvent): Promise<Subscriber[]>; enqueueOnce(input: { deliveryKey: string; event: ShipmentEvent; subscriber: Subscriber; }): Promise<void>; completeRun(runId: string, eventCount: number): Promise<void>; } function verifyTick(tick: Tick, secret: string): boolean { const message = `${tick.runId}.${tick.cutoffIso}`; const expected = createHmac("sha256", secret).update(message).digest(); const supplied = Buffer.from(tick.signature, "hex"); return supplied.length === expected.length && timingSafeEqual(supplied, expected); } export async function runCleanup( tick: Tick, secret: string, store: CleanupStore, ): Promise<{ accepted: boolean; events: number }> { if (!verifyTick(tick, secret)) { throw new Error("invalid tick signature"); } const claimed = await store.claimRun(tick.runId, tick.cutoffIso); if (!claimed) return { accepted: true, events: 0 }; const events = await store.expireShipments(tick.cutoffIso); for (const event of events) { const subscribers = await store.listSubscribers(event); for (const subscriber of subscribers) { await store.enqueueOnce({ deliveryKey: `${event.eventId}:${subscriber.subscriberId}`, event, subscriber, }); } } await store.completeRun(tick.runId, events.length); return { accepted: true, events: events.length }; } HMAC authenticates the trigger; it does not make the job idempotent. RFC 2104 defines the keyed-hash construction, while the run and delivery uniqueness constraints provide replay safety in this design. Keep those concerns distinct. Otherwise a validly signed retry can still duplicate every notification.
There is also an important ordering detail hidden in expireShipments: persist the event that describes each state change in the same transaction as the state change, then enqueue from that durable record. Do not delete the evidence first and hope the network step succeeds later. The durable event is the bridge between transactional cleanup and asynchronous delivery.
I’m not sure what retry ceiling fits every gaming integration; the answer depends on subscriber rate limits and the product’s tolerance for stale shipment status. What should not vary is the terminal state. After the allowed attempts, mark the delivery exhausted, retain enough context for a targeted replay, and alert on the growing count rather than silently dropping it.
Observe the queue, not just the clock
Start with four signals: cleanup runs started, runs completed, pending deliveries, and oldest pending delivery age. Add delivery attempts partitioned by outcome, especially 429, timeout, and accepted responses.
Age shows pain.
Set alerts on user impact, not on a scheduler heartbeat alone. A missing tick matters, but so does a tick that generated 2,000 deliveries while the oldest pending item keeps aging. Google Cloud Pub/Sub’s overview describes the broader decoupled publisher/subscriber model; the same separation is useful even if the implementation is another queue or a self-hosted component.
Test the ugly sequence before deployment — receive, accept, lose the acknowledgement, then retry. Picture shipment ship-1042 and subscriber guild-7: the worker sends delivery key evt-88:guild-7, the receiver records the new expired status, and the acknowledgement disappears on the return trip. The queue cannot know that the write happened, so it sends the same key again. The receiver finds that key in its deduplication store, skips the second state change, and acknowledges the retry. The expected result is one logical shipment update even though the transport attempted it twice. Then invoke the scheduler twice with the same runId, return 429 for just one subscriber, and restart a worker between enqueue and acknowledgement. Confirm that completed subscribers stay completed, the throttled subscriber moves to a later attempt, and the cleanup run remains traceable to every child delivery. These aren't exotic edge cases. They are the normal paths that distinguish a demo from an operable cleanup pipeline.
What about the two common objections?
“Isn't a database timer the simplest?” Yes, when the job stays inside the database. A retention delete, partition detach, or status transition that has no external effect benefits from a short path and one operational owner. Adding an app endpoint and queue to that job would buy little. Once the work includes subscriber-specific network delivery, though, calling the whole flow “one cron job” hides the state operators actually need.
“Does an app cron webhook guarantee exactly-once delivery?” No. Authentication, durable enqueueing, retry policy, and idempotent consumption solve different parts of the problem. Treat delivery as repeatable and make the receiver deduplicate by the stable delivery key. If a receiver cannot do that, serialize attempts for that receiver and expose ambiguous outcomes for review; don't pretend the transport can infer whether a timed-out request was applied.
The decision rule is compact: keep bounded SQL cleanup with the database clock; move orchestration to the app boundary when cleanup produces independently retryable subscriber work. That choice gives logs, metrics, and alerts the same unit operators must recover: one delivery, not one nightly tick.
Further reading
- RFC 2104, “HMAC: Keyed-Hashing for Message Authentication”: https://www.rfc-editor.org/rfc/rfc2104
- Google Cloud Pub/Sub overview: https://cloud.google.com/pubsub/docs/overview



