A PostgreSQL reaper is my default for expiring stale property reservations; use a queue delay when the hold must trigger work at high volume or outside the reservation database. The deciding feature is recovery, not timer precision. A reservation that remains held after its fixed window is operational debt: it blocks inventory, confuses staff, and may produce a duplicate reminder when a retry races the original delivery.

The implementation should record an absolute expires_at, claim work transactionally, and make the expiry operation idempotent. A delayed message is only a wake-up hint. The database remains the authority.

The queue is not the clock.

How should I implement delayed reminders using a message queue?

For a property system, compare two designs. A PostgreSQL reaper periodically selects expired holds, claims a bounded batch, and releases them. It is easy to inspect with ordinary SQL, and recovery is a restart plus another scan. Its weakness is polling: the scan interval becomes part of the user-visible delay, and a large table needs careful indexing.

A message queue can publish one expiry message per hold with a delay, then a Node.js worker verifies the deadline before changing state. This reduces broad polling and lets the worker scale independently. It also introduces delivery uncertainty. Broker retention, consumer acknowledgements, redelivery, dead-letter handling, and clock differences now belong in the runbook. It is a poor fit when the team cannot inspect or replay failed work, or when the database already handles a small, steady expiry workload cleanly.

I choose the reaper when recovery simplicity matters more than near-deadline execution. I choose the delayed queue when the workload is large enough that scanning is the dominant operational burden, and only after the state transition is safe to repeat. The trade-off is explicit: polling spends database work to simplify recovery, while queue delay spends operational complexity to reduce polling.

The same boundary applies to common tools. RabbitMQ provides acknowledgement and redelivery controls, but those controls do not make an application handler idempotent. BullMQ uses Redis-backed queue semantics and delayed jobs, so Redis availability and retry behavior become part of the design. PostgreSQL gives the reaper transactional visibility of the reservation row, but it is not a replacement for a queue when downstream fan-out is the real workload. These are different boundaries, not a quality ranking.

What should the reservation state machine protect?

Use explicit states such as held, expired, and confirmed. The expiry command should be a conditional transition: update only a row still in held state whose expires_at is at or before the worker's current database time. If a guest confirmed the reservation while an expiry message was waiting, the stale message becomes a harmless no-op.

The worker also needs a stable operation key. For example, expire:{reservation_id} can identify logs, metrics, and an outbox record. Do not use a process-local timer as the source of truth; a deploy, crash, or paused event loop can remove it without leaving recoverable evidence.

The queue adapter can stay small. The business operation below is deliberately independent of any broker client:

type Reservation struct { ID string State string ExpiresAt time.Time } func ExpireIfStale(ctx context.Context, store Store, id string, now time.Time) error { res, err := store.Find(ctx, id) if err != nil { return err } if res.State != "held" || res.ExpiresAt.After(now) { return nil } return store.Transition(ctx, id, "held", "expired") } 

In production, Transition must enforce the old state in the database, not just in memory. A worker can receive the same delivery twice, and two workers can receive it concurrently. Both cases must converge on one result.

How do you operate the recovery loop?

Start with a small batch and a covering index for the scan predicate, for example (state, expires_at). A 60-second scan interval may be reasonable for a reservation hold whose business window is measured in minutes, but it is the wrong choice for a deadline that must trigger work immediately. Keep the transaction short. The reaper should claim rows with a lock pattern that allows other workers to continue, then perform the state transition with an old-state condition. If a batch fails halfway through, the next pass should find only the unprocessed rows.

For a delayed queue, publish the reservation identifier and deadline, acknowledge only after the handler has committed the transition, and retry transient storage failures. Acknowledging first creates a silent gap. Retrying every error forever creates a poison message, so route repeatedly failing work to a reviewable dead-letter path with the identifier and failure reason.

The useful alerts are operational: expired holds older than the allowed grace period, queue age, retry count, dead-letter count, and the difference between holds eligible for expiry and holds actually expired. A dashboard that reports only successful deliveries misses the condition staff experience.

Three checks belong in deployment verification. Create a hold with a short test deadline and verify it expires after a worker restart. Confirming the reservation before the deadline must prevent expiry. Replaying the same message must leave one terminal state and one business effect.

Rollback is also part of the design. If a new worker version is unsafe, stop consumers or disable the scheduler while leaving the database state intact, then resume with the previous handler. Never repair a bad release by blindly setting every old hold to expired; inspect the transition predicate and replay only identified records.

Decision rule

Use a PostgreSQL reaper for modest, database-owned expiry where a bounded delay is acceptable and on-call recovery should be boring. Use a delayed queue for high-volume or fan-out-heavy workflows, provided the database transition is authoritative, repeatable, observable, and recoverable after redelivery.

That is the boundary I would put in the runbook: the queue schedules an attempt, while the reservation row decides whether the attempt is still valid. The limitation is practical: a delayed queue is the wrong tool when its retry and dead-letter controls cannot be operated by the on-call team; a reaper is the wrong tool when its scan load threatens the reservation database.

References