Yesterday morning two emails landed in my inbox for a test charter I’d booked earlier in the week. One was a balance-due reminder. The other was a day-before heads-up for the trip. I didn’t trigger either of them by hand. They weren’t on a cron I was watching. And — this is the part I want to talk about — they weren’t being held by Novu, which is the thing I’d normally expect to hold a scheduled message.
I use Novu for every transactional send on Freebo. It’s a nice piece of software. The feature list even includes a delay step inside its workflow DSL — “wait N hours, then send the next node.” Perfect for a reminder, right?
Except for one line in their docs that I had to find out about the hard way:
Freebo bookings get scheduled weeks ahead. A balance-due reminder for a two-week-out charter needs to fire in ~13 days. A “your trip is tomorrow” email needs to go out ~24 hours before the start time, which can be a month after the booking happened. The workflow engine I was leaning on literally cannot hold that long.
So the clock had to live somewhere else.
The pattern
Here’s what settled out. Novu does what it’s good at — rendering, delivery, provider fan-out, consent handling. The when lives in Postgres. One table, one claim function, one worker.
When a booking confirms, I write a row per reminder into notification_dispatches with a scheduled_for timestamp and a payload blob. That’s it. Novu doesn’t know about it yet. The row just sits there with status = 'pending'.
A worker runs every few seconds and asks Postgres for anything ready. This is the part I like:

WITH ready AS (
SELECT dispatch_id, location_id
FROM public.notification_dispatches
WHERE status = 'pending' AND scheduled_for <= now()
ORDER BY scheduled_for, dispatch_id
LIMIT LEAST(GREATEST(COALESCE(p_limit, 20), 0), 100)
FOR UPDATE SKIP LOCKED
)
UPDATE public.notification_dispatches AS dispatch
SET status = 'processing',
attempts = dispatch.attempts + 1,
lease_until = now() + interval '2 minutes',
updated_at = now()
FROM ready
WHERE dispatch.dispatch_id = ready.dispatch_id
AND dispatch.location_id = ready.location_id
RETURNING dispatch.*;
FOR UPDATE SKIP LOCKED is the whole trick. Multiple workers can hit this at the same time and no two of them will ever grab the same row. Nothing to coordinate, no Redis lock, no leader election. Postgres already knows how to do this and it’s been doing it since 9.5.
The two-minute lease_until is the other half. If a worker claims a row and then dies — process crash, OOM, deploy mid-flight — nobody else touches the row until the lease expires. The next claim pass sees status = 'processing' AND lease_until < now(), flips it to unknown, and reconciliation decides whether that message actually hit the provider before the lease ran out. That matters because I’d rather hold a possibly-sent reminder than send a double.
The subtleties
The naive version — “write a due time, poll until due” — is maybe 40 lines. The production version has three things I’d skip past if I were building this fast and then regret.
1. Each reminder has a staleness window. If the worker’s queue is backed up, a “your trip is in 3 hours” email is wrong at 2 hours and useless at 1. A balance-due reminder is a lot more forgiving. So each workflow has its own window:
export const REMINDER_STALE_AFTER_MS = {
'reminder-24h': 4 * 3_600_000, // 4 hours late max
'payment-reminder': 12 * 3_600_000, // 12 hours late max
'reminder-3h': 45 * 60_000, // 45 min late max
'reminder-1h': 20 * 60_000, // 20 min late max
};
If a reminder misses its window, the worker parks it with reason: 'reminder_stale' on the operator’s “Delivery needs attention” list instead of sending it anyway. The copy says “your trip is tomorrow.” That has to still be true when it lands.
2. The itinerary can change out from under a scheduled reminder. Someone rebooks a trip from Saturday to Sunday. The 24-hour reminder I queued three days ago is now wrong — not just the timestamp, the content. So every scheduled payload carries an itineraryRevision:
export function itineraryRevision(startAt, endAt) {
return createHash('sha256')
.update(`${new Date(startAt).toISOString()}:${endAt ?? ''}`)
.digest('hex').slice(0, 20);
}
When the worker is about to send, it re-reads the reservation and checks the revision still matches. If it doesn’t, the dispatch gets skipped as obsolete_itinerary and a fresh one gets scheduled against the new times. The clock gets to lie around for days; the content doesn’t.
3. The claim function is the only cross-tenant query in the whole worker path. Everything else — the actual send, the state update, the reconciliation — is scoped to one location_id. The claim RPC is deliberately the only place that peeks across locations, and the comment above it in the migration file says exactly that so the next person refactoring won’t widen the surface area without noticing.
The vendor’s “scheduler” is almost always a 24-hour delay step wearing a lanyard.
— A thing I keep learning
The point
Yesterday morning’s two emails were, in a small way, the point of all of it. A test booking I’d made a few days earlier produced a scheduled chain of messages that fired on their own timing, re-checked the booking state when they were about to send, and dropped into my inbox at the right times.
Nothing about that is impressive from the outside. From the inside, it’s the quiet payoff of refusing to let a vendor own my clock.
If you’re building anything that promises to notify a human later than tomorrow, check the delay-step limits on your notification tool before you build around them. Mailchimp, Novu, Customer.io, Resend scheduled sends — they all have an opinion about how far ahead they’ll hold a message, and that opinion is usually “not far enough.” Push the clock into your database. It’s one table and one function. Postgres already knows what time it is.
The nice thing about this architecture is it survives you not thinking about it. The worker pulled yesterday morning’s rows, re-checked the reservation, sent the mail, closed the row. I noticed because of the two inbound confirmations in my own inbox — not because anything paged me. That’s the version of “it works” I’m chasing.