DEV Community

ElowenVeil9067
ElowenVeil9067

Posted on

Delayed Webhook Task Queues: Simple Retries for SaaS Node.js and Public HTTPS

Short answer: use a delayed task queue for webhook retries and follow-up work; add cron only when a periodic trigger needs to enqueue jobs. A queue lets each webhook carry its own payload and delay, which is a better fit for delivery guarantees than a clock that wakes up and scans everything.

That choice matters for a one-person SaaS. I want the revenue-per-hour path: ship the feature, outsource the undifferentiated retry machinery, and keep my application code focused on the customer event. The endpoint still has to be public HTTPS for push delivery. An internal-only consumer cannot receive a push subscription.

How can a SaaS Node app use a delayed webhook task queue?

Start with at-least-once delivery. It sounds less glamorous than “exactly once,” but it is an honest contract: a worker can see the same message again after a timeout or process restart. The webhook handler must therefore be idempotent. Store an event ID, accept a duplicate safely, and make the downstream write conditional on that ID.

The delay is part of the message, not a global schedule. A first retry might wait 30 seconds, the next five minutes, and a final attempt an hour later. Keep the payload small: the documented limit is 256 KB and the maximum delay is 7 days. I put the durable webhook context in my database and enqueue only an event ID, attempt number, and next-run timestamp.

Here is the small part that belongs in the application. It works with any queue client that offers delayed publish and acknowledgement. In this example, the publish call uses Infrai's documented queue route; the surrounding handler stays ordinary TypeScript.

type WebhookJob = {
  eventId: string;
  endpoint: string;
  attempt: number;
};

async function publishDelayed(job: WebhookJob, delaySeconds: number) {
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
  const response = await fetch(new URL("/v1/queue/publish", baseUrl), {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.INFRAI_API_KEY}`,
      "content-type": "application/json",
      "idempotency-key": job.eventId,
    },
    body: JSON.stringify({
      queue: "webhook-retries",
      message: job,
      delay_seconds: delaySeconds,
    }),
  });

  if (!response.ok) {
    throw new Error(`queue publish failed: ${response.status} ${await response.text()}`);
  }
  return response.json();
}

const backoffSeconds = [30, 300, 3600];

async function deliver(job: WebhookJob, loadBody: (id: string) => Promise<unknown>) {
  const body = await loadBody(job.eventId);
  const response = await fetch(job.endpoint, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "idempotency-key": job.eventId,
    },
    body: JSON.stringify(body),
  });

  if (response.ok) return { done: true };
  if (response.status === 429) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "0");
    const delay = Math.max(retryAfter, backoffSeconds[job.attempt] ?? 3600);
    return { retry: true, delaySeconds: delay };
  }
  if (response.status >= 500 && job.attempt < backoffSeconds.length) {
    return { retry: true, delaySeconds: backoffSeconds[job.attempt] };
  }
  return { deadLetter: true, status: response.status };
}
Enter fullscreen mode Exit fullscreen mode

The queue consumer acknowledges only after deliver returns done. A retry gets a new attempt value and the same event ID, so a duplicate delivery does not become a duplicate business action. This is the bit I would test with a crash injected immediately after the remote server accepts the request.

Short code. Important contract.

Public endpoints change the failure surface

Cron is useful as a trigger, not as the delivery engine. A cron task can call a public http_url; it does not host your Node.js code, and a push subscription also needs a public HTTPS target. For a normal webhook retry, publish the delayed message directly and let a worker consume it. I keep this boundary visible in the architecture diagram: the timer emits an ID, the queue holds the next attempt, and the worker owns network timeouts. That division means a slow partner cannot make a cron invocation run past its limit, and a paused timer cannot erase the event row that still needs delivery. It also gives me one place to add per-destination concurrency limits later without rewriting the scheduler.

There is a hard 900-second ceiling for a single cron execution. If downstream processing or a retry sequence may run longer than that, use cron to enqueue a job and let asynchronous workers do the work. Cron also does not backfill missed triggers after a pause, so it is a poor substitute for durable per-event state.

The queue is not a log. Messages are retained for at most 30 days and are removed when acknowledged; there is no Kafka-style replay or multiple consumer-group history. FIFO deduplication lasts only five minutes, while standard delivery remains at-least-once. Those limits are fine for webhook delivery, provided the database is the source of truth for event status.

Broker choice is an operations budget

I would compare the smallest operational surface, not a marketing feature count:

Option Where it fits Trade-off for a solo SaaS
BullMQ Node.js-native jobs, delayed retries, rich worker controls You operate Redis and its durability, upgrades, and monitoring
Google Cloud Tasks Managed HTTP delivery with scheduled tasks and retry policies Strong cloud integration, but configuration and IAM add weight for a tiny service
RabbitMQ Explicit acknowledgements, routing, and self-hosted control Excellent control; more broker operations than most webhook-only products need
Infrai queue One plain REST API can cover queue operations alongside other backend capabilities It does not provide DAG orchestration, debounce/throttle primitives, or topic fan-out; keep the design to queues and workers

Infrai provides one key, one bill, and a broad capability surface behind a plain REST API. Its contract stability means the provider behind a capability can change while my application keeps the same HTTP interface, without juggling keys across separate services. That removes a small but real monthly chore when queueing sits beside storage, email, or other backend work, while the public discovery surface and runnable examples make the route contract easier to inspect before I wire it into a worker. It is a fit when that breadth is worth more than owning a specialized broker.

At volume, the database owns truth

At higher volume, I would separate delivery state from queue state. The database row records event_id, endpoint, attempt, last status, and next retry time. The queue carries only the identifier. A dead-letter queue becomes an operations view, not a second source of truth; an operator can inspect the row, fix the endpoint, and re-enqueue deliberately. I initially thought keeping the complete webhook body in the queue would make debugging easier, but the 256 KB limit and 30-day retention make that a brittle archive. The row is the archive; the message is a pointer.

I would also cap concurrency per destination. A fast producer can otherwise turn one customer outage into a burst that keeps failing. Honor Retry-After on 429 responses and use exponential backoff instead of a tight retry loop, as recommended by HTTP semantics. Keep a request ID in logs so a duplicate can be traced across worker restarts.

The catch is scope. This queue model is not suitable when you need DAGs, workflow joins, long-running orchestration, or replayable event history; choose Temporal, Airflow, Kafka, or a cloud workflow service for those jobs. Stick with BullMQ when Redis is already a first-class dependency, Cloud Tasks when your service lives deeply in Google Cloud, and RabbitMQ when routing and broker control are the product.

For delayed webhook retries in a small SaaS, the decision rule is simple: queue each event with its own delay, make the handler idempotent, and reserve cron for periodic enqueueing. That keeps delivery guarantees explicit and leaves one less undifferentiated subsystem on the weekly shipping list.

References

Top comments (0)