Build1 publisher3 min readPublished
Postgres holds the weekly digest schedule so an operator can see what is due
A dev.to post puts the next run, the delivery state and the idempotency key in one Postgres row and leaves the delayed-job queue carrying only a job identifier, because the failures it designs for are a paused sender and an exhausted email quota.
The Engineer · Build desk

What happened
- A dev.to post makes Postgres the schedule of record for a weekly customer-support digest, has cron wake a Node.js worker to find due rows, and treats any delayed-job queue as an optional accelerator.
- The deciding constraint it names is recovery: after a bad deploy, a paused sender or an exhausted email quota, an operator has to see what is due, reclaim abandoned work and resend without duplicating a digest.
- One row per customer holds the time-zone rule, the next eligible instant, the delivery state, the attempt count and a stable idempotency key, and cron's only job is to wake a worker often enough.
- Due rows are leased to one worker at a time, and the lease carries an expiry so a recovery pass can return work claimed by a process that then died to the eligible set.
- The post concedes that a queue-first design can fit better at higher volume and a pure cron loop can be cheaper at very low volume, and that both still require durable delivery state.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- capability Reading the row at claim time means a customer who changes time zone, goes inactive or opts out mid-week gets the current answer, which a message published six days earlier cannot hold.
- constraint Because the send and the commit cannot be one atomic operation, exactly-once is unavailable whatever the schema; the most a team can build is a ledger in which duplicates are visible.
- cost A bounded poller charges database load against how often workers wake rather than how much work exists, and the post asks for volume, load, retention and operator-time figures before naming the crossover with delayed messages.
- decision Teams that already run a queue have to decide what travels in the message: this design allows a job identifier only, pushing every policy read back into Postgres at receive time.
The design the post rejects is the tempting one: a cron expression for every customer, sending immediately inside the callback [10]. Per-user schedules are then hard to enumerate, the callback owns the work only while it runs, and a process restart leaves never attempted and sent but not recorded looking identical [10]. One global wake-up schedule plus a materialized next run for each customer makes those states explicit [11]. Cron's remaining responsibility is to wake a worker often enough to find due rows [7].
The schedule table uses five states [23]. `running` means one worker owns a lease, and `sent` means the provider accepted the delivery operation and the application committed that outcome [14]. Those are two events, and the post is explicit that sending an email and committing a database transaction cannot be one atomic operation [19].
Claiming is where concurrency gets decided. Multiple workers may wake at once, so each due row is leased to one while the others skip it [16]. Size the batch by time: keep it small enough that the lease comfortably exceeds normal processing time, then renew deliberately if processing may run longer [18].
The bridge across the send-and-commit boundary is a key derived from the logical delivery, such as `customerId + digestWeek + channel`, stored before sending and passed to a provider that honors idempotency when one is available [20]. Where the provider does not, the post recommends a delivery ledger that makes duplicates observable, and warns against pretending a local `sent` boolean creates exactly-once behavior across a network call [21]. The outbox record is inserted in the same transaction that makes a digest eligible; a dispatcher claims outbox rows, sends them, records the result, and publishes to the queue from the outbox if a queue is part of the design [22].
The queue's job shrinks to match. Publish only a job identifier, and verify the current database state when the worker receives it [9]. Keep the customer activity check close to the claim or send transaction, so an old queued message cannot revive an obsolete reminder [15]. A message scheduled six days ahead is awkward as the sole record when a customer changes time zone, becomes inactive, or opts out [8]. The post's own framing for all of this is authority versus transport [5].
Whether this transfers depends on conditions the post names. Being a few seconds late has to be less damaging than sending twice or silently skipping a week [6]. The context is a small team serving customers in Europe and the US, and the post says this is not the lowest-latency design [3]. On cost it declines to name a winner without request volume, database load, queue retention and operator time, and says the crossover "depends on the workload, not the product category" [12][13]. At sustained scale, it says, repeatedly scanning for sparse work may cost more than emitting delayed messages [12].
What to watch
- Whether the post publishes the claim query and lease-renewal code, since its batch-size rule depends on measured processing time.
- A worked version of this design at sustained volume, with database load figures for polling sparse work.
- Provider-side support for idempotency keys, because the customerId + digestWeek + channel key only bridges the send-and-commit boundary if the sender honors it.