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
| Capability | DIY (cron + database) | Posthook |
|---|---|---|
| Per-task check-later timer | Row with next_check_at, polled on cron cadence | Schedule a hook with postIn — fires at the right time, no scanning |
| Self-rescheduling | Update next_check_at in the database, wait for next cron tick | Handler schedules a new hook if task is not ready |
| Handler model | Cron processes batch; handler checks each task’s status | Hook fires individually; handler checks one task and acts or reschedules |
| Retry on failure | Build retry logic into the polling worker | Built-in retries with configurable backoff |
| Delivery visibility | Query your own logs or build tracking | Per-hook status with attempt history |
| Failure alerting | Build monitoring around the cron job | Per-endpoint anomaly detection with alerts |
| Long-running checks | Manage worker timeouts | Async hooks: return 202, ack/nack within timeout up to 3 hours |
| Infrastructure | Cron + database table + polling query + retry logic + monitoring | API 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”) orpostAtfor 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.tomlwith 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.