How to Build Reliable Reminders and Follow-Ups
Reminders and follow-ups are simple to describe but become coordination work once they need cancellation, retries, and delivery visibility. Here is how to build them durably.
Send a trial reminder 3 days before expiry. Nudge a user who abandoned onboarding. Follow up on an unresolved alert until it clears — these are some of the most common timing patterns in backend systems.
The core model is the same in every case: schedule a timer when the triggering event happens, and when it fires, check whether the action is still needed. The user already onboarded? Skip. The trial was renewed? No-op. The handler checks current state and decides.
Common use cases
- Onboarding reminders — nudge users who signed up but haven’t completed a key step
- Trial expiry warnings — remind users 3 days, 1 day, and 1 hour before their trial ends
- Abandoned flow nudges — follow up on incomplete checkouts, unfinished forms, or stalled signups
- Invitation follow-ups — remind invitees who haven’t accepted after a few days
- Notification follow-up chains — re-alert on an unresolved failure, then escalate or stop when it clears
The naive approach
The most common starting point is a cron job that scans for due reminders:
SELECT * FROM reminders
WHERE send_at <= NOW()
AND sent = false
AND cancelled = false;
This works at low volume. Each reminder is a row in a table, and the cron job polls every minute to find what is due.
Where it breaks
State accumulates fast
Every user action that creates a reminder adds a row. Every cancellation adds a flag. Every retry adds state. The reminders table grows, the polling query slows, and the cron interval becomes a precision floor — poll every minute, and reminders can fire up to 59 seconds late.
Every reminder carries batch complexity
The cron job processes reminders in bulk — query for all due rows, iterate, check each user’s state, decide whether to send. Each reminder in the batch needs its own state check, its own skip logic, its own error handling. With durable scheduling, each reminder is its own unit. The handler receives one delivery for one user, checks one state, and acts or skips. No batch iteration, no interleaved state checks.
Retries are an afterthought
The cron job fires and forgets. If the email API is down, the reminder is lost unless you build retry logic — attempt tracking, backoff, a dead letter path. This is real infrastructure bolted onto a polling loop.
Failures are silent
A reminder endpoint starts returning 500s. Nothing alerts you. Users stop getting reminders, and you find out when someone complains. Silence is the default with cron.
Deploys lose state
If reminders are scheduled with in-memory timers (setTimeout, node-schedule), a deploy wipes them. Persistent storage solves this, but now you are maintaining a scheduling system.
DIY vs Posthook
| Capability | DIY (cron + database) | Posthook |
|---|---|---|
| Per-user timer creation | Insert a row, poll for due rows on a cron cadence | Schedule a hook via API — fires at the exact time |
| Handler model | Cron job processes batch; handler checks flags to skip already-acted users | Hook fires individually; handler checks state and acts or skips |
| Retry on failure | Build retry logic, track attempts, handle backoff | Built-in retries with configurable backoff, jitter, and per-hook overrides |
| Delivery visibility | Query your own logs or build a status dashboard | Per-hook status (pending, retry, completed, failed) with attempt history |
| Failure alerting | Build monitoring around the cron job | Per-endpoint anomaly detection with alerts via email, Slack, or webhook |
| Timezone / DST | Convert to UTC manually, handle DST transitions in code | postAtLocal + timezone with automatic DST handling |
| Infrastructure | Cron job + database table + polling query + retry logic + monitoring | API key + endpoint |
A better approach
Instead of polling for what is due, schedule each reminder at the moment the triggering event happens. User signs up → schedule a reminder for 48 hours later. User is invited → schedule a follow-up for 3 days out. Each event creates its own timer.
The timer is persisted externally and survives deploys, restarts, and scaling events. If delivery fails, retries happen automatically. If your reminder endpoint starts failing, you get alerted.
When the reminder fires, your handler checks the current state and decides what to do. Did the user already complete onboarding? Return success and do nothing — that is the normal happy path, not an error case. Is the user still inactive? Send the reminder. The handler is always a simple state check followed by an action or a no-op.
How Posthook fits
Posthook handles the durable timing and delivery. Your handler owns a simple decision: check state, then act or skip.
- Schedule per-user reminders via API with
postAtLocalfor timezone-aware delivery with DST handling,postInfor relative delays, orpostAtfor exact UTC deadlines - Per-hook retry overrides — a critical payment reminder can get a more aggressive retry strategy than a marketing nudge, without changing project defaults
- Anomaly detection — know when your reminder endpoint starts failing before users notice. Posthook tracks failure rates per endpoint against historical baselines and alerts via email, Slack, or webhook.
- Per-delivery observability — inspect each reminder’s attempt history, status, and failure reason from the dashboard
- Bulk replay — if a downstream outage caused a batch of reminders to fail, retry them all in one API call with time-range filtering
Posthook provides at-least-once delivery — your handler may receive the same delivery more than once due to retries or edge cases. Since the handler checks state before acting, duplicates resolve as no-ops. You can also cancel a pending hook by ID to skip the delivery, but since the handler checks state regardless, cancellation is a cleanup optimization.
When not to use Posthook
If the reminder is a coarse recurring digest — “send a daily summary email to all users” — a cron job that queries for eligible users is probably the simpler approach. Posthook is a better fit when each user, each order, or each event creates its own timer with its own deadline.
If you need complex branching logic — “if the user opened the email, send variant B; otherwise send variant C” — that is a workflow orchestration problem. Consider a workflow platform like Inngest or Temporal. Posthook handles the timing layer, not the branching logic.
Getting started
Install the SDK and schedule your first reminder:
npm install @posthook/node
Schedule a reminder for 48 hours after signup:
import Posthook from '@posthook/node';
const posthook = new Posthook('phk_...');
const hook = await posthook.hooks.schedule({
path: '/webhooks/onboarding-reminder',
postIn: '48h',
data: { userId: 'user_123', step: 'complete-profile' },
});
When the hook fires, your handler checks state and decides:
// In your /webhooks/onboarding-reminder handler
const user = await db.getUser(payload.userId);
if (user.profileCompleted) {
// Already done — no-op
return res.status(200).json({ status: 'skipped' });
}
await sendOnboardingReminder(user);
return res.status(200).json({ status: 'sent' });
For a timezone-aware reminder (e.g., “remind at 9am in the user’s local time”):
const hook = await posthook.hooks.schedule({
path: '/webhooks/trial-reminder',
postAtLocal: '2026-03-15T09:00:00',
timezone: 'America/New_York',
data: { userId: 'user_123', reminderType: 'trial-expiry' },
}); Frequently asked questions
Ready to get started?
Create your free account and start scheduling hooks in minutes. No credit card required.