Short answer: for fixed-window reservation expiry, I would make an every-minute cron trigger call a public webhook endpoint that only admits work, then let a queue-backed Node.js worker perform rate-limited processing with idempotent claims and retries.
Don't make the minute tick carry the business workload. Its job is smaller: authenticate, name the due-time slice, enqueue it once, and return. That split turns an awkward overlap problem into two observable stages.
| Pick this shape | When it fits | Main trade-off |
|---|---|---|
| Cron calls a webhook; webhook enqueues a time slice | The scheduler and application have a clean HTTP boundary | The endpoint is public infrastructure and must be authenticated |
| Cron runs an in-process command that enqueues | One deployment owns scheduling and admission | A deploy or restart couples the clock to the application |
| One delayed message per reservation | The queue supports the required delay and cancellation model | Reschedules and cancellations create more message lifecycle work |
For a B2B SaaS reservation system, I choose the first shape when holds expire on a fixed boundary and downstream release calls have a strict request budget. The scheduler stays boring. The worker gets the hard job.
Good. Keep it dull.
How should a cron trigger queue every minute for rate-limited Node.js processing?
Use a two-stage control loop. In words, the diagram is: clock -> authenticated webhook -> unique time-slice message -> due-reservation claims -> paced release calls -> completion ledger. The arrow between admission and execution is the important one. It prevents a slow release batch from keeping the webhook open or causing the next minute to start a second copy of the same batch.
The webhook should derive a stable slice such as 2026-09-10T08:31, not invent a random job identity. If the scheduler retries that minute, the queue adapter's enqueueOnce contract rejects the duplicate key while the endpoint can still return an accepted response. This is idempotent admission. It doesn't yet make reservation release idempotent; that needs a second key at the item level.
Keep the endpoint reachable from the scheduler but not anonymous. Verify a signature over the raw body, reject stale delivery timestamps, and compare the signature without leaking timing information. The sample below leaves those mechanics behind an interface because the exact envelope belongs to the scheduler you operate. The HTTP boundary is public; the authorization policy is yours.
An every-minute schedule also needs an explicit overlap policy. Some schedulers can start a new run while the prior run remains active; Kubernetes documents concurrency policies for CronJobs and warns that scheduling is approximate, so jobs should be idempotent. Even if your trigger is an external HTTP scheduler rather than a CronJob, the design lesson transfers: a tick is an attempt, not proof that exactly one execution happened.
Short tick. Durable work.
Pick admission-first when the release path has a hard rate limit
Admission-first is the strongest fit when the expiration scan is quick but releasing inventory, seats, or capacity calls a constrained downstream service. The webhook can acknowledge after one durable enqueue operation. Workers then control throughput independently of the cron cadence.
Rate limiting needs two controls, not one. Cap concurrency to bound simultaneous work, and pace starts to honor a requests-per-interval rule. A pool of five workers is not automatically five requests per second; fast responses could let that pool issue hundreds of requests inside a minute. Conversely, a fixed delay alone may waste capacity when calls are slow. Name both settings, measure both, and treat the downstream contract as the ceiling.
Retries sit after classification. Walk through one ugly sequence before choosing the policy: the 08:31 tick claims reservation r-1842, the release service commits the operation, and the connection closes before the worker receives a response. At 08:32, another tick can discover that the reservation still looks unfinished locally. If the second attempt carries a fresh identity, it can ask for the same state change twice; if it carries expire:r-1842 and the completion ledger is checked first, every layer has a stable name for the operation. Persist the terminal result, and send the same key on every retry when the downstream protocol accepts idempotency keys. A 429 is different from malformed input: honor a valid retry delay, reschedule the attempt, and do not burn through a tight local retry loop. Permanent validation failures go to review or a dead-letter path with enough context to diagnose the item. The clock did its job in this sequence. Ambiguous completion is the real failure mode.
This is the part teams tend to compress into “add retries.” Don't. The retry policy is business logic — especially when releasing a stale hold changes what another customer can buy.
Dead-letter handling is not automatic recovery. The AWS SQS documentation describes a dead-letter queue as a place for messages that were not processed successfully and notes that retention periods need deliberate configuration. Whatever queue you use, alert on the age and count of parked expiration work, preserve the reservation and idempotency identifiers, and define who replays it. A dead-letter queue nobody watches is storage, not an operating procedure.
Replay is a feature.
Go deep on the Node.js webhook and worker contract
The following TypeScript is intentionally adapter-based. It shows the contracts that must remain stable across a webhook process, a queue, a reservation store, and a rate-limited worker without pretending that an in-memory array is durable infrastructure.
type Tick = { slice: string; requestedAt: string; }; type Reservation = { id: string; expiresAt: string; }; interface TickQueue { enqueueOnce(key: string, tick: Tick): Promise<"enqueued" | "duplicate">; } interface ReservationStore { claimDue(before: string, limit: number): Promise<Reservation[]>; isComplete(idempotencyKey: string): Promise<boolean>; markComplete(idempotencyKey: string): Promise<void>; retryLater(reservation: Reservation, availableAt: string): Promise<void>; park(reservation: Reservation, reason: string): Promise<void>; } interface ReleaseClient { release( reservation: Reservation, idempotencyKey: string, ): Promise<{ status: 204 | 400 | 404 | 409 | 429; retryAfterMs?: number }>; } function minuteSlice(date: Date): string { return date.toISOString().slice(0, 16); } export async function admitTick( rawBody: string, signature: string | undefined, queue: TickQueue, verifySignature: (body: string, signature: string | undefined) => boolean, now = new Date(), ): Promise<{ status: 202 | 401; body: string }> { if (!verifySignature(rawBody, signature)) { return { status: 401, body: "unauthorized" }; } const slice = minuteSlice(now); const result = await queue.enqueueOnce(`expiry-tick:${slice}`, { slice, requestedAt: now.toISOString(), }); return { status: 202, body: result }; } The HTTP framework glue should preserve the raw request body for signature verification and map the returned object to the actual response. I haven't selected a framework here because doing so would hide the boundary under framework-specific parsing. The contract is the useful bit.
Now drain one slice. startGapMs expresses pacing, while batchSize bounds each claim. The store must implement claims atomically so two workers cannot own the same due reservation at once.
const wait = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); export async function processExpiryTick( tick: Tick, store: ReservationStore, client: ReleaseClient, options: { batchSize: number; startGapMs: number; maxRetryDelayMs: number }, ): Promise<void> { const due = await store.claimDue(`${tick.slice}:59.999Z`, options.batchSize); for (const reservation of due) { const key = `expire:${reservation.id}`; if (await store.isComplete(key)) { continue; } const result = await client.release(reservation, key); if ([204, 404, 409].includes(result.status)) { await store.markComplete(key); } else if (result.status === 429) { const delayMs = Math.min( result.retryAfterMs ?? options.startGapMs, options.maxRetryDelayMs, ); await store.retryLater( reservation, new Date(Date.now() + delayMs).toISOString(), ); } else { await store.park(reservation, `release-status:${result.status}`); } await wait(options.startGapMs); } } The 404 and 409 branches count as complete only if your release contract defines them as “already absent” and “already released.” If it doesn't, change the classification. I'm not sure what codes your downstream uses, and guessing would be dangerous; a contract test against its documented responses resolves that question.
Before deployment, test three sequences: send the same minute twice, run two workers against the same due set, and make a release succeed while its response is lost. Each sequence should produce one completed ledger entry per reservation. Then test sustained 429 responses and confirm retries move into the future without blocking unrelated reservations.
The minimum useful telemetry is compact. Log slice, reservationId, idempotencyKey, attempt, and outcome as structured fields. Count admitted ticks, duplicate ticks, completed releases, retries, and parked items. Track the age of the oldest due reservation as the service-level signal; queue depth alone can look healthy while one poison item waits for hours. Alert on missed admission windows and growing oldest-item age, not on every individual retry.
Where this pattern stops fitting
The catch is the public webhook. It adds signature rotation, replay protection, ingress monitoring, and another network dependency. Stick with an in-process scheduled command when one trusted deployment owns the clock and queue, and that coupling is acceptable. Prefer one delayed message per reservation when expiry times vary widely, cancellations are easy to express in your queue, and minute-sized scans would mostly find nothing.
This design is also not suitable when a one-minute boundary is too coarse. Use a scheduler and storage model that match the real deadline; don't fake sub-second guarantees by polling faster. Your mileage may vary on batch size and pacing because downstream quotas, call latency, and reservation arrival patterns determine them. Load tests with representative bursts should set those values.
Cost belongs in the decision, but there is no universal winner. Compare scheduler invocations, queue operations, empty scans, retained retry messages, worker runtime, and the on-call cost of replay. Those dimensions are more durable than a single request price.
References
- Kubernetes, “CronJobs”: https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
- AWS, “Using dead-letter queues in Amazon SQS”: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
Further reading starts with the two primary references above: one covers scheduling semantics and concurrency, while the other covers dead-letter queue behavior and retention.

