How to Build Reliable Expiration and Timeout Checks

Expiration logic looks simple until every record has its own deadline. Here is how to build durable per-object expiration timers that fire reliably and tell you when they fail.

Expiration is one of the most common backend timing patterns. Expire a pending invitation after 7 days. Release a held reservation after 30 minutes. Enforce a trial window down to the hour. Cancel an unpaid order before inventory locks up.

Each of these is a per-object deadline. The action needs to fire at a specific time — not “sometime in the next cron cycle” — and it needs to fire reliably even through deploys, restarts, and downstream outages.

Common use cases

  • Invitation expiry — mark an invitation as expired if it hasn’t been accepted within a deadline
  • Pending order timeout — release held inventory if the payment is not completed within a window
  • Reservation holds — free up a slot if the user doesn’t confirm within the hold period
  • Trial window enforcement — downgrade access when the trial period ends
  • Account deletion grace period — schedule the actual data purge after a cooling-off period, with cancellation if the user changes their mind

The naive approach

A cron job that scans for expired records:

SELECT * FROM orders
WHERE expires_at <= NOW()
  AND status = 'pending';

Run it every minute, process the results, update the status. At low volume, this works fine.

Where it breaks

Scanning overhead

The polling query runs against every pending record on every cron tick. At tens of thousands of pending records, this becomes a performance concern. Indexes help, but the fundamental problem is that you are scanning for work instead of scheduling it.

Missed deadlines

The cron interval is a precision floor. Poll every minute, and a reservation that should expire at 10:00:30 does not get processed until 10:01:00 — or later if the previous batch took time to process. For time-sensitive expirations like payment holds, 30-60 seconds of drift matters.

Coordination across instances

Multiple instances running the same cron job means the same record gets processed concurrently. Without advisory locks, FOR UPDATE SKIP LOCKED, or Kubernetes concurrencyPolicy, you get duplicate expiration actions — two emails sent, inventory released twice, or race conditions on status updates.

Weak visibility

A cron job does not track individual expirations. If an expiration action failed — the downstream API was down, the database write conflicted — there is no built-in record of the failure, no retry, and no alert. You find out when a customer asks why their invitation is still showing as pending.

DIY vs Posthook

CapabilityDIY (cron + database)Posthook
Per-object expiration timerInsert a row with expires_at, poll on a cron cadenceSchedule a hook at the exact deadline — fires once, at the right time
Handler modelCron job processes batch; handler checks status to skip already-resolved objectsHook fires individually; handler checks state and expires or skips
Retry on failureBuild retry logic into the cron job or a separate queueBuilt-in retries with configurable backoff, jitter, and per-hook overrides
Delivery visibilityQuery your own logs or build trackingPer-hook status with attempt history and failure reasons
Failure alertingBuild monitoring around the cron jobPer-endpoint anomaly detection with alerts via email, Slack, or webhook
Timezone / DSTConvert to UTC manually, handle transitions in codepostAtLocal + timezone with automatic DST handling
Scanning overheadGrows with the number of pending recordsNone — each timer fires independently
InfrastructureCron + database table + polling query + locking + retry logic + monitoringAPI key + endpoint

A better approach

Schedule the expiration at the moment the object is created. Order placed → schedule an expiration check for 30 minutes later. Invitation sent → schedule an expiry for 7 days out. Trial started → schedule the downgrade for the end of the trial window.

Each object gets its own timer. No scanning, no polling, no precision floor.

When the timer fires, your handler checks the current state and decides what to do. Order already paid? Return success — nothing to expire. Still pending? Expire it. The handler is a simple state check followed by an action or a no-op. It is always safe to run, which means retries, duplicate deliveries, and race conditions all resolve naturally.

How Posthook fits

Posthook handles the durable timer and delivery. Your handler owns a simple decision: check state, then expire or skip.

  • Schedule per-object timers via API with postAt for exact UTC deadlines, postIn for relative delays (“expire in 7 days”), or postAtLocal + timezone for user-local expiration windows with DST handling
  • Per-hook retry overrides — critical expirations like payment holds can get a more aggressive retry strategy without changing project defaults
  • Anomaly detection — know when your expiration endpoint starts failing. Posthook tracks failure rates per endpoint against historical baselines and alerts via email, Slack, or webhook before users are affected.
  • Bulk replay — if a database outage caused a batch of expiration checks to fail, retry all failed hooks in a time range with one API call
  • Per-delivery observability — inspect each expiration timer’s attempt history and outcome from the dashboard

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 expiration is a nightly batch cleanup — “archive all orders older than 90 days” — a cron job that queries by date range is probably simpler. Posthook is a better fit when each object has its own deadline and the expiration needs to fire at a specific time.

If the expiration logic involves complex conditional workflows — “if the user is on a grace period, extend; if they upgraded, cancel; if they downgraded, apply different limits” — that decision logic belongs in your handler. Posthook handles when to fire the check, not the branching logic of what to do.

Getting started

Install the SDK and schedule your first expiration timer:

npm install @posthook/node

Schedule an invitation expiry for 7 days out:

import Posthook from '@posthook/node';

const posthook = new Posthook('phk_...');

await posthook.hooks.schedule({
  path: '/webhooks/invitation-expired',
  postIn: '7d',
  data: { invitationId: 'inv_456', teamId: 'team_789' },
});

When the hook fires, your handler checks state and decides:

// In your /webhooks/invitation-expired handler
const invitation = await db.getInvitation(payload.invitationId);

if (invitation.status !== 'pending') {
  // Already accepted or cancelled — no-op
  return res.status(200).json({ status: 'skipped' });
}

await db.expireInvitation(invitation.id);
return res.status(200).json({ status: 'expired' });

Schedule a payment hold timeout at an exact deadline:

const hook = await posthook.hooks.schedule({
  path: '/webhooks/order-timeout',
  postAt: '2026-03-12T15:30:00Z',
  data: { orderId: 'order_001' },
  retryOverride: {
    minRetries: 5,
    delaySecs: 10,
    strategy: 'exponential',
    backoffFactor: 2.0,
    maxDelaySecs: 300,
  },
});

Frequently asked questions

Ready to get started?

Create your free account and start scheduling hooks in minutes. No credit card required.