DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

Gaming Holds: Schedule a Node.js Webhook Follow-Up Task an Hour Later with Queue or Cron

When a gaming reservation expires, the dangerous part is not how to schedule a webhook follow-up task an hour later. It is deciding which reservation is still allowed to expire after a player renews it, a worker restarts, or the same delayed message is delivered twice. Short answer: use a delayed queue message for the normal one-hour event, but make the database state and a generation token authoritative; use cron as a recovery sweep when inspection and durability matter more than timer latency.

Timers lie.

They say when to attempt work, not whether the work is still valid. The Node.js process can calculate the deadline, but it cannot make a later network call exactly-once. That distinction should shape the architecture before anyone compares queue products with cron syntax.

Start with the reservation state, not the timer

The reservation record needs a durable expires_at, a status such as held, and a generation that changes when the hold is renewed. The scheduling message carries the reservation ID and the generation that existed when the message was created. At expiry time, the worker performs a conditional state transition. A stale message then becomes a harmless no-op instead of cancelling a newly renewed hold.

The useful invariants are concrete:

  • A process restart cannot erase the due time.
  • A duplicate delivery cannot create a second release.
  • A worker that wakes late can still identify overdue work.
  • A webhook timeout after the receiver commits has a defined retry outcome.
  • The queue message stays small, carrying a reference when the reservation body is large.

The database is the business ledger. The queue or cron runner is an attempt mechanism. Confusing those roles is how a clean one-hour feature becomes a recovery incident: a scheduler says a job ran, while the reservation table still says the slot is held, or a retry applies an old event to a new generation.

If the follow-up also sends an external webhook, use an outbox row for that side effect. Write the reservation change and outbox row in one database transaction, then let a dispatcher publish the work. A crash between publishing and acknowledgement can produce duplicates; that is normal for at-least-once delivery, so the receiver or an operation ledger needs the stable operation key. In a game, consider the sequence where a player reserves a tournament slot, loses connectivity, reconnects and renews the hold, and then the original one-hour message arrives while two workers are competing for it: the old generation must fail its conditional update, the renewed row must remain held, the notification outbox must retain its own operation identity, and the metrics must distinguish stale work from a genuinely failed release, because otherwise an apparently healthy queue can hide a growing class of business-invalid messages behind successful transport acknowledgements.

Which failure modes decide queue versus cron?

The choice changes once the failure boundary is explicit. A delayed queue gives each reservation its own due event and tends to keep delivery latency low. A database-backed cron sweep gives operators a queryable backlog and a natural way to recover overdue rows after a pause. Per-event cron sits between those models, carrying the scheduler configuration burden without making the database the obvious source of truth.

Failure or requirement Delayed queue Database row plus cron sweep Per-event cron
One due time for each reservation Natural fit Natural fit Possible
Worker restart before acknowledgement Retry the message Reclaim or reselect the row Depends on scheduler semantics
Show every overdue item Usually indirect Direct query Requires scheduler inspection
Renewal makes an old event stale Generation check required Generation check required Job cancellation and state check required
Delay beyond queue retention Boundary to verify Good fit Depends on retained schedules
Low operational overhead at game-event volume Usually better Sweep and claim logic required Usually worse as events grow

The table is the decision record, not a promise of exact timing. Queue delay, worker capacity, retry backoff, and network latency all contribute to when the webhook is observed. If the product says “release at exactly 60 minutes,” rewrite that requirement; a distributed worker can target a due time, but it cannot guarantee a wall-clock instant without defining what late delivery means.

The catch is that the queue is not a recovery database. It may be the wrong choice when operators need arbitrary searches, a long audit trail, or a delay outside its documented retention window. A cron sweep is not automatically cheaper either: it adds polling, row-claiming, lease recovery, and monitoring. Choose based on the failure you need to explain at 03:00, not just the line of code that creates the timer.

How do you schedule a Node.js webhook follow-up task an hour later?

The critical path is a conditional database update. The example uses Python because this article keeps code in one language, but the same SQL and transaction boundary apply to a Node.js worker.

from dataclasses import dataclass


@dataclass(frozen=True)
class ExpiryMessage:
    reservation_id: str
    generation: int


def expire_reservation(db, message: ExpiryMessage) -> str:
    # Retries and stale messages are safe because the update is conditional.
    result = db.execute(
        """
        UPDATE reservations
        SET status = 'expired'
        WHERE reservation_id = %s
          AND generation = %s
          AND status = 'held'
          AND expires_at <= CURRENT_TIMESTAMP
        """,
        (message.reservation_id, message.generation),
    )

    if result.rowcount == 1:
        return 'released'
    return 'already_released_or_renewed'
Enter fullscreen mode Exit fullscreen mode

The message should not contain a trusted copy of the entire reservation. Store authoritative details in the database or durable object storage, and send the ID, generation, and payload reference. The worker reads current state before attempting the webhook. That extra read is cheap compared with releasing the wrong player's slot.

There are two valid side-effect orderings, and neither removes the need for idempotency. Marking the reservation expired first means a failed webhook must be represented by an outbox record that can be retried. Calling the webhook first means a timeout can hide a successful receiver commit, so the receiver must accept the same operation key again. A longer sleep does not solve either ambiguity.

Test the states that make the design earn its keep: restart before acknowledgement, duplicate consumers, renewal immediately before expiry, timeout after the receiver commits, clock skew, a paused scheduler, and a worker that vanishes after claiming a row. Those tests say more than a happy-path assertion that a message appears after an hour.

When is a cron sweep the right boundary?

Use a recurring sweep when the database must be able to rediscover work. Persist expires_at, status, an attempt count, and whatever lease fields the worker needs. Each run selects a bounded batch of rows with expires_at <= CURRENT_TIMESTAMP, claims them, and enqueues or performs bounded work. It should not hold an unbounded set of webhook calls inside one scheduler run.

PostgreSQL documents FOR UPDATE SKIP LOCKED for avoiding waits on rows already locked by another transaction, which is useful when several sweep workers claim separate batches. A lock alone is not a lease: if a worker disappears after claiming a row, another run needs a deliberate reclaim rule. Make that rule visible in the data model.

This is where cron earns its place. A missed run becomes a set of overdue rows on the next run, rather than a vanished event. It also supports reconciliation and cleanup naturally. The trade-off is polling delay and another operational loop to observe: sweep duration, claim age, backlog size, and webhook retry age all need metrics.

Per-event cron is still valid for a small, controlled set of one-shot schedules when operators need each schedule independently visible. It is a poor default for every player reservation because event volume becomes scheduler configuration, and cancellation and renewal now require managing both the reservation row and the schedule.

The practical rule for a one-hour hold

For the gaming scenario, schedule one delayed message per reservation, carry a generation token, and let the worker make a conditional database transition. Keep the due time in the reservation record even when the queue owns the near-term wait. That gives the fast path a clear trigger and gives operations a durable answer when a message is late or missing.

Switch to a database sweep when the delay crosses the queue's documented boundary, when the backlog must be searched and replayed, or when the team already has a well-operated polling service. Stay with per-event cron only when the number of schedules is deliberately small and its visibility is worth the lifecycle work. Move to a workflow system when expiry becomes a multi-step process involving joins, compensation, or human review.

Your mileage may vary. I am not sure a lower timer latency is worth a new operational dependency if the receiver cannot make retries idempotent. The right answer is the one whose stale, late, and duplicated states are explicit enough to test.

Sources

References

Top comments (0)