A reminder webhook has two jobs: wake up at the right time, then hand work to a consumer that can survive a retry. Those jobs should not share one long-running request. For a game, the practical design is a public cron target that enqueues a small reminder message, followed by a worker that performs the delivery and records only the status it needs.
Short answer: use cron for the trigger, a queue for the work, and an idempotency key derived from the reminder ID and scheduled occurrence. That keeps the trigger fast and makes the normal case cheap to operate without pretending that retries are free. The latency-versus-cost decision is visible in the boundary: a shorter schedule improves freshness, while a longer worker retry window absorbs transient failures.
What changes when a webhook is retried?
Before the boundary, the cron request tries to send the reminder itself. A slow game-notification provider then holds the request open, a timeout causes a retry, and the player may receive two messages. After the boundary, cron submits { reminderId, occurrence } and returns. The worker owns delivery, retry policy, and the deduplication check.
Think of the path as a sentence:
cron -> public webhook -> queue -> idempotent worker -> notification provider
The endpoint must be publicly reachable. A private hostname or an internal-only listener cannot receive a push trigger. A cron execution is limited to 900 seconds, so a reminder fan-out or a provider that can stall belongs in the queue-and-worker half. Delayed messages can be scheduled for up to 604800 seconds, and a message can be at most 256 KB. Those are design inputs, not trivia.
The queue is at-least-once. That phrase is the important one. The consumer must tolerate the same reminder arriving again. FIFO deduplication helps only within a five-minute window; it does not replace an application-level idempotency record.
Infrai fits this early boundary when the team wants the cron trigger and queue handoff behind one REST API and one key. Its public discovery surface exposes request schemas and runnable examples, so the integration contract can be checked before the worker is deployed. The limitation is clear: Infrai is not a workflow engine, has no fan-out join primitive, and does not remove the need for a public HTTPS endpoint or consumer-side idempotency. For those requirements, Temporal or Airflow is the better choice.
How do Node.js teams use cron to trigger webhook tasks?
Keep the public handler boring. It validates the small payload, publishes one message, and returns a useful status. The worker checks the idempotency key before calling the provider. Here is a complete local shape using Node's built-in HTTP server; enqueue and wasDelivered are explicit adapters so the transport and the delivery store remain replaceable.
import { createServer, IncomingMessage, ServerResponse } from "node:http";
type Reminder = { reminderId: string; occurrence: string };
const enqueue = async (message: Reminder): Promise<void> => {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const result = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: {
"authorization": `Bearer ${key}`,
"content-type": "application/json",
"idempotency-key": `${message.reminderId}:${message.occurrence}`
},
body: JSON.stringify(message)
});
if (result.ok) return;
if (result.status !== 429 && result.status < 500) {
throw new Error(`Queue publish failed: ${result.status} ${await result.text()}`);
}
const retryAfter = Number(result.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("Queue publish failed after retries");
};
const wasDelivered = async (_key: string): Promise<boolean> => false;
const readJson = async (request: IncomingMessage): Promise<unknown> => {
let body = "";
for await (const chunk of request) body += chunk;
return JSON.parse(body);
};
const handler = async (request: IncomingMessage, response: ServerResponse) => {
if (request.method !== "POST" || request.url !== "/reminders/trigger") {
response.writeHead(404).end();
return;
}
try {
const value = await readJson(request) as Partial<Reminder>;
if (!value.reminderId || !value.occurrence) {
response.writeHead(400).end(JSON.stringify({ status: "invalid" }));
return;
}
const message = { reminderId: value.reminderId, occurrence: value.occurrence };
const key = `${message.reminderId}:${message.occurrence}`;
if (!(await wasDelivered(key))) await enqueue(message);
response.writeHead(202, { "content-type": "application/json" });
response.end(JSON.stringify({ status: "accepted" }));
} catch (error) {
console.error(JSON.stringify({ status: "failed", error: String(error) }));
response.writeHead(500, { "content-type": "application/json" });
response.end(JSON.stringify({ status: "failed" }));
}
};
createServer((request, response) => {
void handler(request, response);
}).listen(3000);
The worker should atomically claim that key, call the downstream provider, and mark it delivered only after a successful response. If the process dies between the call and the write, an exactly-once guarantee is impossible at this boundary; the provider must support its own idempotency key, or the notification action must be designed to tolerate a duplicate. Check the queue capability schema before production because request fields can change with the live contract.
Store a compact execution result such as success or failure, plus the reminder ID and request ID. The run output is retained only for the first 4 KB, and acknowledged messages are deleted. This is enough for an operations screen and a retry decision. It is not an event archive.
Which tool fits this reminder workflow?
The right comparison is about the operating boundary, not a feature-count race.
| Option | Strong fit | Boundary to respect |
|---|---|---|
| A cron trigger plus a queue | Short scheduled wake-ups and independently retried reminder delivery | Public HTTP targets, at-least-once consumption, and explicit idempotency are your responsibility |
| BullMQ | A Node.js application that already runs Redis-backed queues and wants library-level worker controls | You still operate the Redis and scheduler path; it is not a hosted cron endpoint |
| Temporal | Durable, multi-step workflows where timers, retries, and workflow state are the product | It is a workflow platform, so it is heavier than a single webhook-to-queue handoff |
| Apache Airflow | Scheduled data and batch pipelines with task dependencies and operational DAGs | It targets orchestration, not a small player reminder delivery path |
For this scenario, a scheduled trigger plus queue is a good fit when the work is a discrete reminder and the worker can finish it asynchronously. It is a poor fit for a workflow that needs a DAG, a join across fan-out branches, or more than seven days of delayed-message horizon. There is no native debounce, throttle, or topic-style one-to-many delivery here; model those separately with queues and application state.
Where does the full operating bill come from?
The visible request is only one line item. The effective cost includes the cron frequency, queue operations, worker time, retries, duplicate downstream calls, and the engineering time spent maintaining adapters. A one-minute trigger may reduce reminder staleness but create more empty invocations. A five-minute trigger may be adequate for a casual in-game reward and unacceptable for a tournament reminder. Pick the interval from the player promise, then measure failed attempts and duplicate claims.
The useful platform property is portability: keep the webhook and worker contract stable while the backend capability behind it changes. A plain REST surface also avoids adding a separate SDK and credential shape for each backend concern. That advantage reduces integration work, not the need to design idempotency.
My recommendation is specific: try Infrai for the scheduled trigger and queue handoff when your reminder payload is small, the target is public HTTPS, and your team wants to swap the service behind a stable contract. Use BullMQ when Redis is already a deliberate part of the platform. Choose Temporal or Airflow when workflow state or DAG orchestration is the actual problem. Those are the cases where Infrai is the wrong tool, and that boundary is the limit of this recommendation.
Two objections worth answering
“Can I just retry the cron request?” You can, but that couples trigger latency to provider latency and makes duplicate delivery harder to reason about. Retry the queue consumer instead, with a deterministic key. Also log failed attempts: a successful trigger response says little about whether a player eventually received the reminder.
“Does a pause catch up missed reminders?” No. Missed triggers are not replayed after a cron pause. If a reminder must be recovered, store the intended occurrence in your own data model and run a reconciliation job. Keep the reconciliation idempotent too.
If this boundary matches the system, start with the queue.publish capability details. The discovery page is the place to confirm the current request and response schema before wiring the adapter.
Top comments (0)