Short answer: use cron only to find due reminders, then hand each one to a queue worker with a stable idempotency key; choose a durable workflow when delivery becomes a long, stateful process.
| Approach | Best fit | Main limitation |
|---|---|---|
| Cron plus queue worker | Short reminder jobs with bounded retries | Your database lease and dedupe contract are application code |
| Durable workflow | Multi-step waits, approvals, and resumable state | More concepts, state, and operational surface |
That is the decision. A cron expression says when to look. It does not make an HTTP call reliable.
How can I use cron to trigger webhook tasks for user reminders?
The answer is a handoff, not a longer cron expression.
What fails when a marketplace sends user reminders?
Consider a marketplace with pending seller reminders. Every five minutes, a scheduler finds rows whose send_at timestamp has passed. It posts a small webhook task to an internal worker endpoint. The worker loads the reminder, sends the message, and records the outcome.
The failure cases are ordinary:
- The scheduler times out after the remote service accepted the request.
- The worker receives the same task twice after a retry.
- The database write succeeds, but the process dies before the response returns.
- A reminder becomes due while its previous attempt is still running.
- A destination accepts the request but responds too slowly for the caller timeout.
A retry policy cannot distinguish every one of those outcomes. It can try again. The receiver must make a repeated attempt harmless, or a durable state machine must know which step completed.
I start with the smallest useful contract: one due-reminder scan, one task identifier, one bounded delivery attempt, and one durable outcome. Configuration bloat is a failure mode too. Five scheduling layers do not repair an ambiguous state transition.
How should cron hand work to a Node.js worker?
Keep the cron process boring. It should claim due rows and enqueue tasks; it should not hold a web request open while a message provider or webhook destination does its work. A database claim needs a lease so another scheduler can recover abandoned work.
Here is a generic TypeScript contract. The queue can be self-hosted or managed. The application boundary stays the same.
type ReminderTask = {
reminderId: string;
taskKey: string;
attempt: number;
};
interface ReminderStore {
claimDue(limit: number, leaseMs: number): Promise<ReminderTask[]>;
markQueued(taskKey: string): Promise<void>;
}
interface TaskQueue {
enqueue(task: ReminderTask, options: { dedupeKey: string }): Promise<void>;
}
async function enqueueDueReminders(
store: ReminderStore,
queue: TaskQueue,
): Promise<number> {
const tasks = await store.claimDue(100, 90_000);
for (const task of tasks) {
await queue.enqueue(task, { dedupeKey: task.taskKey });
await store.markQueued(task.taskKey);
}
return tasks.length;
}
The order matters. claimDue must be recoverable, and taskKey must remain stable for the same logical reminder. Do not derive it from the current timestamp. A retry is a new delivery attempt for one task, not a new reminder.
The batch size of 100 and the 90-second lease are explicit starting values, not universal tuning advice. Measure scan duration, worker latency, webhook timeout, and recovery delay. Then change the values from evidence.
A worker should check state before sending. If another attempt already marked the task delivered, it exits. This guard prevents a late retry from sending a second message after a successful first attempt.
interface ReminderRepository {
get(taskKey: string): Promise<{
status: "queued" | "sending" | "delivered" | "retryable";
destination: string;
} | null>;
beginSend(taskKey: string): Promise<boolean>;
complete(taskKey: string, deliveredAt: string): Promise<void>;
}
async function deliverReminder(
task: ReminderTask,
repository: ReminderRepository,
postWebhook: (url: string, body: unknown) => Promise<void>,
): Promise<void> {
const reminder = await repository.get(task.taskKey);
if (!reminder || reminder.status === "delivered") return;
const acquired = await repository.beginSend(task.taskKey);
if (!acquired) return;
await postWebhook(reminder.destination, {
reminderId: task.reminderId,
taskKey: task.taskKey,
});
await repository.complete(task.taskKey, new Date().toISOString());
}
There is an uncomfortable boundary here: an external receiver may perform the side effect and fail before the worker records delivered. Exactly-once delivery across two independent systems is not something a cron flag can create. Use an idempotency key that the receiver understands, or put the side effect behind a service that can enforce that key.
The subtle case is a successful remote side effect followed by a local crash. The next attempt sees no delivered row and sends again. A timeout does not tell the worker which side of that boundary completed. The stable key has to travel with the request, and the receiver has to retain enough outcome state to reject a duplicate. That is more work than adding another retry, but less work than explaining duplicate seller notifications after the fact.
Is a queue enough, or does this need a durable workflow?
Ask how much state exists between the first timer and the final outcome. A reminder that makes one request and retries three times is queue work. A process that waits 48 hours, branches on a seller response, sends a follow-up, and can be inspected step by step is workflow work.
Queue workers usually expose attempts, delays, visibility timeouts, and dead-letter handling. Durable workflow systems persist execution state and timers so a process can resume after a worker disappears. That extra model helps with long-lived coordination, but it asks the team to learn workflow semantics, replay rules, and activity boundaries.
Three real options show different boundaries:
| Option | Delivery model | Useful boundary | Limitation |
|---|---|---|---|
| BullMQ | Redis-backed jobs with delayed and retryable work | Compact tasks and familiar worker code | Application state and delivery idempotency remain yours |
| Google Cloud Tasks | Scheduled HTTP dispatch with retry settings | A handler-centered task queue | The handler still owns duplicate-safe effects |
| Temporal | Durable workflow code with separate activities | Long waits and multi-step recovery | A larger programming and operating model |
These are engineering differences, not a leaderboard. A periodic reminder scan should not acquire a workflow model just because it sounds safer. A multi-day seller verification flow will eventually need durable state, and hiding that state inside queue metadata makes recovery harder to reason about.
My rule is blunt: if the retry decision can be made from one task record and a bounded attempt counter, use the queue path. If the decision depends on several completed steps over time, use a durable workflow.
What should retries and idempotency mean?
Retry policy needs two separate answers. Which failures are retryable? Network timeouts, connection resets, and temporary server failures often deserve another attempt. Invalid payloads, rejected authentication, and a destination that is permanently gone need a different path. What happens if the receiver already acted? The answer must not depend on guessing from the HTTP response.
Use an attempt schedule with a cap. For example, retry at 1, 5, and 20 minutes, then move the task to review or a dead-letter state. Add jitter so a large batch does not wake every worker at once. The exact values belong in version-controlled configuration with an owner and a reason.
The idempotency key should represent the business operation, such as reminder:{accountId}:{reminderId}:{deliveryRevision}. It should remain stable across transport retries and change only when the business operation is intentionally new. Store the key with the outcome.
Do not accept arbitrary callback URLs from marketplace records. If the system posts to user-controlled or imported destinations, validate and constrain destinations before making server-side requests. The OWASP SSRF guidance covers allowlists, network controls, and the limits of URL validation. A retry loop can multiply the impact of one unsafe destination.
Observability should follow that same key. Log the task key, reminder ID, attempt number, outcome class, latency, and next retry time. Track duplicates separately from ordinary failures. A dashboard that reports only server-error counts can hide the expensive bug: successful work repeated twice.
When is the runner-up the better choice?
Durable workflows are the better fit when the reminder is one step in a process that must survive days, deployments, and changing worker capacity. They make waiting and resumption explicit. That can justify the extra model when operators need to inspect a specific execution and understand which activity produced its current state.
A queue worker is the better fit when the task is short, independent, and easy to replay from a compact record. It is easier to test with ordinary integration tests and easier for a small team to explain during an incident. Its limitation is clear: the database lease, dedupe key, timeout, and dead-letter policy must be real parts of the design.
For the marketplace cleanup and reminder case, I would ship the queue path first: scan due rows, claim them, enqueue stable task keys, make the receiver idempotent, and cap retries. I would move to a durable workflow when the reminder becomes a long-lived conversation rather than one delivery operation. That boundary is more useful than a blanket rule about which category of tool is superior.
Top comments (1)
Keep the HTTP call timeout below the task lease, the time a worker holds the task. If the lease expires first, the next scheduler pass treats it as abandoned and sends again while your first request is still open. Two deliveries for one task. I built Webhook Relay to hold and retry sends like that.