How to Build Durable Check-Later Workflows

Many integrations return 'not ready yet.' Here is how to build durable per-task check-later workflows without running a custom polling system.

Not every integration is event-driven. You kick off an async job — a report generation, a payment settlement, a third-party verification — and the response is “accepted, check back later.” Now you need to check again in 5 minutes, and again if it is still not ready, and eventually give up or escalate if it never completes.

This is a check-later workflow: per-task, durable, and self-rescheduling. It is not a polling loop — it is a chain of scheduled checks, each one deciding whether to act, reschedule, or give up.

Common use cases

  • Async task status checks — start a long-running job (report generation, video processing, data export) and check back for completion
  • Order or payment verification — submit a payment and verify settlement status after a delay
  • Device or IoT health checks — check a device’s status periodically after a provisioning event
  • Partner system follow-ups — call a partner API that processes asynchronously and poll for results
  • Post-deploy verification — deploy a service and verify it is healthy after a grace period

The naive approach

A cron job that scans for pending tasks:

SELECT * FROM async_tasks
WHERE status = 'pending'
  AND next_check_at <= NOW();

Run it every minute, check each task, update the status or reschedule the next check.

Where it breaks

Wasted scans

The cron job queries all pending tasks every tick, even when most are not due yet. At thousands of pending tasks, this is wasted work — scanning a table to find the handful that are ready to check.

Poor per-task timing

The cron interval is a precision floor. A task that should be checked in 30 seconds waits up to a minute. A task that should be checked in 5 minutes gets scanned 5 times before it is due. Per-task timing does not map to a fixed cadence.

Operational burden

The polling worker needs error handling, state management, retry logic, and coordination to avoid duplicate checks across instances. Each of these is infrastructure you maintain alongside the business logic.

Weak visibility

Which tasks are pending? How many checks has this task gone through? Did the last check fail or succeed? With a cron scanner, you need to build this visibility yourself.

DIY vs Posthook

CapabilityDIY (cron + database)Posthook
Per-task check-later timerRow with next_check_at, polled on cron cadenceSchedule a hook with postIn — fires at the right time, no scanning
Self-reschedulingUpdate next_check_at in the database, wait for next cron tickHandler schedules a new hook if task is not ready
Handler modelCron processes batch; handler checks each task’s statusHook fires individually; handler checks one task and acts or reschedules
Retry on failureBuild retry logic into the polling workerBuilt-in retries with configurable backoff
Delivery visibilityQuery your own logs or build trackingPer-hook status with attempt history
Failure alertingBuild monitoring around the cron jobPer-endpoint anomaly detection with alerts
Long-running checksManage worker timeoutsAsync hooks: return 202, ack/nack within timeout up to 3 hours
InfrastructureCron + database table + polling query + retry logic + monitoringAPI key + endpoint

A better approach

Schedule each check when the async task starts. Task submitted → schedule a check for 5 minutes later. When the check fires, your handler queries the task status and decides what to do next.

The chain builds itself: each check either completes the workflow or schedules the next check. No scanning, no polling loop, no cron cadence to tune. Each task follows its own timeline.

How Posthook fits

Posthook provides the durable check-later trigger. Your handler owns the status check and the decision about what to do next.

  • Schedule per-task timers with postIn (“check again in 5 minutes”) or postAt for exact timestamps
  • Self-rescheduling from the handler — if the task is not ready, schedule another check. This creates a durable polling chain without a polling loop.
  • Async hooks — if the status check itself takes time (calling a slow external API, waiting for a query to complete), return 202 and ack/nack when done. Configurable timeouts up to 3 hours.
  • Anomaly detection — know when your check-later endpoint starts failing before pending tasks pile up silently
  • Per-delivery observability — inspect each check’s attempt history, status code, and outcome from the dashboard
  • Sequences for recurring system checks — periodic health polls, analytics refreshes, and reconciliation jobs can use sequences defined in posthook.toml with calendar scheduling and config-as-code

Your handler should be safe to run more than once — Posthook provides at-least-once delivery. If the task was already completed by a previous check, return success and skip.

When not to use Posthook

If the external system provides a webhook callback when the task completes, use that instead. A callback is better than polling when it is available.

If the workload requires continuous high-frequency polling across many tasks — checking thousands of tasks every few seconds — that is a different problem. Posthook is designed for moderate-frequency, per-task re-checks, not high-throughput polling infrastructure. If the volume and frequency push beyond what fits naturally in the pricing model, consider a purpose-built polling system or stream processor.

Getting started

Install the SDK and schedule your first check-later workflow:

npm install @posthook/node

Start an async task and schedule the first check:

import Posthook from '@posthook/node';

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

// Task submitted — schedule first status check
await posthook.hooks.schedule({
  path: '/webhooks/check-task-status',
  postIn: '5m',
  data: { taskId: 'task_123', checksRemaining: 5 },
});

When the hook fires, your handler checks and decides:

// In your /webhooks/check-task-status handler
const task = await externalApi.getTaskStatus(payload.taskId);

if (task.status === 'completed') {
  await processResult(task);
  return res.status(200).json({ status: 'completed' });
}

if (payload.checksRemaining <= 0) {
  await escalate(payload.taskId);
  return res.status(200).json({ status: 'escalated' });
}

// Not ready — schedule another check
await posthook.hooks.schedule({
  path: '/webhooks/check-task-status',
  postIn: '5m',
  data: { taskId: payload.taskId, checksRemaining: payload.checksRemaining - 1 },
});
return res.status(200).json({ status: 'rescheduled' });

Frequently asked questions

Ready to get started?

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