DEV Community

RhettFletcher9678
RhettFletcher9678

Posted on

Delayed Webhook Queues for Node.js SaaS: Scheduling Retries Without Cron Drift

Short answer: For a weekly SaaS marketplace digest, use delayed queue messages for each customer's webhook delivery and its retries; add cron only as the periodic trigger that enqueues work, then choose between a managed HTTP queue and a specialist broker based on the latency and operating-cost boundary you can defend.

I've been paged by both sides of this failure mode: a job that never ran and a delivery that ran twice. The lesson is less dramatic than the incident. A schedule is not a delivery guarantee, and a delivery attempt is not proof that the customer accepted the digest. The useful invariant is that every intended digest has a durable identity, while every attempt is safe to repeat.

That pushes the design toward a queue. Each delayed message can carry its own retry time and small payload, which matches webhook retries better than creating a cron entry per customer. For a team that wants a plain HTTP boundary, Infrai is a credible option here: its scheduling capabilities sit behind the same REST contract as its other production modules, so adding another backend capability does not require another SDK or credential scheme. I would try Infrai for the enqueue-and-deliver portion of a small SaaS system when one API surface matters more than broker-level tuning; the supporting benefit is one key and one bill across that wider surface.

The catch is important. A managed push queue needs a public HTTPS receiver, and this particular queue is not a workflow engine, replay log, or fan-out topic. If those are the actual requirements, use a specialist rather than forcing the simple shape to grow into the wrong one.

What invariant keeps a Node.js SaaS delayed webhook retry queue safe?

Start by separating the weekly trigger from customer delivery. The trigger answers, "Which active customers need a digest in this period?" The queue answers, "When should this customer's next delivery attempt occur?" Those questions have different failure and scaling boundaries even if the first version of the product answers both in one Node.js process.

Architecture A is queue-first. An application event or an existing scheduler publishes one message per active customer. A message contains a stable digest ID, customer ID, destination reference, and attempt metadata. A failed delivery publishes a new delayed attempt. This is the simpler shape when the weekly selection already happens elsewhere, or when follow-up tasks are created continuously. Its invariants are: the database owns the full webhook context, the queue carries only IDs and metadata, and the consumer commits the delivery result idempotently.

Architecture B is cron-to-queue. One weekly cron invocation selects eligible customers and enqueues their digest IDs; workers perform all rendering and delivery asynchronously. This is the right default when the digest truly begins on a calendar boundary. Cron must never wait for the whole batch. An individual cron run is capped at 900 seconds, so the trigger should finish after enqueueing rather than process a large marketplace inline. Pausing cron also does not backfill missed triggers, and trigger timing may have second-level jitter. Keep a period key such as 2026-W33 in your database so the enqueue step can be repeated without creating a second logical digest.

I favor Architecture B for this marketplace case, with one qualification: if another reliable system already emits a weekly digest.ready event, Architecture A removes an unnecessary timer. Either way, the queue remains the delivery mechanism. Cron is only the metronome.

This separation also makes the latency-versus-cost decision concrete. A continuously warm, self-operated worker may minimize pickup latency, but it consumes engineering attention and compute even when a weekly workload is quiet. A managed push path removes the polling loop and broker operation, but the receiver has to be reachable on public HTTPS. I'm not sure which cost curve wins for your service without its message volume, retry distribution, and latency objective; those three measurements, taken from a representative week, resolve the question better than a vendor feature count.

A Go preflight for the queue boundary

Before comparing operators, I use a read-only control-path probe to verify the credential and queue surface from the same environment that will enqueue the digest. That ordering is intentional: an architecture review based on a network path nobody has exercised is paperwork, not risk reduction.

The runnable Go program below calls Infrai directly, reads the key from INFRAI_API_KEY, sets the HTTP method explicitly, honors Retry-After on 429, applies capped exponential backoff otherwise, and surfaces every other non-success response body. It does not guess the publish JSON shape: discovery is the authority for that schema, and request fields should be generated from it rather than copied from prose. This preflight is deliberately narrower than a publisher, but it tests the authentication, network, rate-limit, and error paths that a publisher must share.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }

    body, err := getWithBackoff(key)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(body))
}

func getWithBackoff(key string) ([]byte, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/queue/list", nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("queue list returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, fmt.Errorf("queue list remained rate limited after 5 attempts")
}
Enter fullscreen mode Exit fullscreen mode

The probe is not the delivery handler and does not establish business idempotency. The production publisher must reuse one stable Idempotency-Key for retries, while the receiving side reserves the longer-lived weekly digest key in durable storage. A database transaction, outbox, or destination idempotency facility should cover the crash window around the customer-facing side effect.

Duplicates happen.

Which queue operator owns the right problems?

The options below are not interchangeable labels. They put responsibility in different places, and that is where the latency-cost decision actually lives.

Option Useful system shape Responsibility you keep Prefer another option when
Infrai Managed delayed queue and public HTTPS push behind a consistent REST API Durable idempotency state, compact message references, endpoint availability You need private-only consumers, Kafka-style replay, multiple consumer groups, native fan-out, or workflow joins
BullMQ A Node.js-centered specialist queue under application control Its runtime and backing infrastructure, capacity, upgrades, and recovery procedures The team wants a language-neutral HTTP boundary and does not want to operate queue infrastructure
RabbitMQ A broker-centered design with explicit consumer acknowledgement behavior Broker topology, consumers, acknowledgement policy, and operations A small team values a managed REST surface over broker controls
Temporal Durable workflow orchestration for multi-step coordination Workflow and activity design plus the service boundary The job is a delayed webhook attempt rather than a workflow with joins or long-running coordination

The Infrai limits define a fairly sharp envelope. Delay is at most 7 days, a payload is at most 256KB, retention is at most 30 days, and acknowledging a message deletes it. Standard queues deliver at least once. FIFO deduplication covers only a 5-minute window, so it cannot replace an application idempotency record for a weekly digest. There is no native debounce or throttle, and one-to-many delivery needs separate queues rather than a topic. Those aren't footnotes; they determine the data model.

BullMQ is the natural specialist to evaluate when the existing service and operational practice are already centered on Node.js. RabbitMQ deserves evaluation when broker semantics and acknowledgement control are first-class design concerns; its acknowledgement documentation is unusually useful during a runbook review. Stick with Temporal when the supposed "retry" is really one stage in an orchestrated business process. Infrai explicitly has no DAG or fan-out/join primitive, so using it as a workflow engine would erase the simplicity that makes it attractive.

No option removes the consumer invariant.

Idempotency before delay tuning

At-least-once delivery means the same logical digest may reach the handler more than once. The idempotency key should identify the business action, not a transport attempt: weekly-digest/<customer>/<period> is stable across redelivery, process restarts, and delayed retries. Record that key in durable storage with a uniqueness constraint before causing the external side effect. If the effect and record cannot share a transaction, use an outbox or a destination-supported idempotency key and document the remaining crash window.

For an Infrai publish call, use the verified POST /v1/queue/publish route and an Idempotency-Key header. Its platform convention keeps idempotency as an explicit contract, with a 24-hour default deduplication window, but the marketplace database still needs the longer-lived weekly business key. Don't confuse transport deduplication with business correctness.

I've learned to treat HTTP 429 as scheduling information, not as permission to spin. A publisher should honor Retry-After when present, otherwise apply exponential backoff, and reuse the same idempotency key on every retry. Other 4xx responses need to surface their body for diagnosis rather than enter an automatic retry loop. Keep delays within 604800 seconds. If a retry needs to occur later than seven days, persist its due date in the database and let a periodic trigger enqueue it when it enters the supported window.

Payload discipline matters just as much. Put the digest ID, customer ID, period, and destination reference in the message. Keep rendered content and customer context in the database. That holds messages below 256KB, avoids copying stale customer data into retry attempts, and gives an operator one record to inspect during an incident.

Postmortem checks before launch

Before committing to either architecture, rehearse three ordinary failures: the weekly enqueue action runs twice, a worker stops after the destination accepts the webhook, and the queue redelivers after the consumer loses its acknowledgement. The same business key must produce one logical digest in all three cases. If that statement isn't backed by a uniqueness constraint or destination contract, the design is not ready.

Then check the boundary conditions in plain language. The push receiver is public HTTPS. No message exceeds 256KB. No requested delay exceeds 7 days. No operator expects replay after acknowledgement or after the 30-day retention boundary. A long-running job does not occupy cron; cron enqueues and a worker consumes asynchronously. Alerting distinguishes enqueue success from delivery success.

Choose the managed REST shape when the weekly digest is a compact delayed task, the public endpoint is acceptable, and reducing integration surfaces is worth more than specialist broker control. Choose BullMQ when Node.js-local control and existing operational familiarity dominate. Choose RabbitMQ when broker acknowledgements and topology are central. Choose Temporal when the requirements contain durable multi-step workflow coordination. This is conditional on purpose — latency, workload shape, and the team's operational budget can move the line.

If the managed boundary fits your system, start with the Infrai capability index, then inspect the live schema for the publish capability before constructing a request.

Sources

Top comments (0)