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

CapabilityDIY (cron + database)Posthook
Per-user timer creationInsert a row, poll for due rows on a cron cadenceSchedule a hook via API — fires at the exact time
Handler modelCron job processes batch; handler checks flags to skip already-acted usersHook fires individually; handler checks state and acts or skips
Retry on failureBuild retry logic, track attempts, handle backoffBuilt-in retries with configurable backoff, jitter, and per-hook overrides
Delivery visibilityQuery your own logs or build a status dashboardPer-hook status (pending, retry, completed, failed) with attempt history
Failure alertingBuild monitoring around the cron jobPer-endpoint anomaly detection with alerts via email, Slack, or webhook
Timezone / DSTConvert to UTC manually, handle DST transitions in codepostAtLocal + timezone with automatic DST handling
InfrastructureCron job + database table + polling query + retry logic + monitoringAPI 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 postAtLocal for timezone-aware delivery with DST handling, postIn for relative delays, or postAt for 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.