DEV Community

ZorvynGale1729
ZorvynGale1729

Posted on

Message Queue Timeout Issues: BullMQ vs SQS for Delayed Webhook Delivery

The constraint that changes this decision is the business deadline. A renewal reminder can arrive late and be useless, but a retry can also deliver it twice. For a delayed webhook that must wait until a deadline, I would choose Amazon SQS when the team wants a managed queue contract, and BullMQ when Redis is already a deliberate part of the worker platform. RabbitMQ is a strong fit when routing and broker control matter more than a narrow delayed-job workflow.

Short answer: timeouts are mitigated by bounded retries, explicit consumer timeouts, and a dead-letter path. The queue does not make a slow webhook reliable by itself. The consumer must make each delivery safe to repeat, and the endpoint must answer within the queue and cron trigger SLA.

Exactly.

No shortcuts.

This is a boundary.

I have been paged by missed jobs and duplicate deliveries. I first treated the delay setting as the main design choice; later I found that the delivery contract mattered more: when may the reminder run, how long may one attempt run, what makes a retry safe, and where does a permanently failing message go?

How should a message queue handle timeout issues when delivering delayed webhooks?

Suppose a developer-tools service schedules a renewal reminder for a business deadline. The reminder is a webhook to a customer-owned endpoint. The scheduled task publishes a message; a worker consumes it; the worker calls the endpoint; the endpoint may be slow or temporarily unavailable. The business rule is simple: do not send before the deadline, and do not lose the reminder because one HTTP attempt timed out.

That rule needs two separate clocks. The first is the schedule clock: the delayed message must not become visible before the deadline. The second is the attempt clock: one HTTP call needs a finite timeout so a worker cannot hold a delivery forever. Increasing the second clock until the endpoint eventually responds is not a recovery plan. It only reduces worker capacity while the failure is happening.

For a standard queue, assume at-least-once delivery. A worker can receive a message, make the remote call, time out locally, and then see the same message again even if the remote service processed it. That is the duplicate-delivery edge. Give the reminder a stable event ID, derive an idempotency key from it, and make the receiving side record or enforce that key before applying the business effect. HMAC signing can authenticate the payload, but it does not make a request idempotent; those are separate controls.

The retry policy should be bounded. Retry transient transport errors and 5xx responses with exponential backoff, honor a server-provided Retry-After value when present, and stop after a defined attempt budget. Move the message to a dead-letter queue after that budget. A DLQ is an investigation surface, not a magic second queue: retain the original event ID, attempt count, last status, and failure reason so an operator can decide whether a redrive is safe.

Here is the small part of a worker I want to see in a review. It consumes through a REST queue contract, carries its key from the environment, checks every response, and backs off on 429 rather than spinning. It deliberately marks the event as processed only after the downstream call succeeds, while the idempotency store prevents a later delivery from repeating an already completed effect. The store needs an atomic check-and-set in production; the in-memory implementation is only a runnable shape for the decision. That distinction matters during a partial outage: a worker can be healthy, authenticated, and still unable to complete a remote delivery within its deadline.

package main

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

func consume(ctx context.Context, client *http.Client) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        baseURL := os.Getenv("INFRAI_BASE_URL")
        if baseURL == "" {
            return nil, fmt.Errorf("INFRAI_BASE_URL is required")
        }
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/queue/consume", 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 == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if value, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                wait = time.Duration(value) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("consume failed: %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("consume exceeded retry budget")
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    body, err := consume(context.Background(), client)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

There is a subtle race here: claiming before the call prevents two workers from running concurrently, but a process crash after the remote side accepts the request and before Complete can cause a later retry. That is why the receiver must honor the same stable key. The queue cannot solve a two-system commit.

BullMQ, SQS, or RabbitMQ for this contract?

The three options solve different operational problems. BullMQ is a Redis-backed job library with delayed jobs and worker-oriented primitives. It is attractive when Redis is already operated for the application and the team wants queue behavior close to application code. Its boundary is operational coupling: Redis availability, persistence, failover, and queue retention become part of the delivery system you own.

Amazon SQS is a managed queue service with standard at-least-once delivery and a dead-letter queue model. It is the least surprising choice for a team that wants the broker off its pager and is already inside AWS. The trade is that delivery timing is not a precise business clock, and application-level idempotency remains mandatory. A delayed reminder still needs a deadline check in the worker; visibility timeout is not an HTTP timeout and should not be treated as one.

RabbitMQ gives a team more explicit broker topology and routing control. Exchanges, bindings, acknowledgements, and dead-letter exchanges are useful when one event must be routed by rules that would be awkward in a single work queue. That control brings more broker operations and more topology to test. For one delayed renewal webhook, it can be more machinery than the problem needs.

There are two other credible choices. Inngest and Trigger.dev fit teams that want application-level durable functions and scheduled workflow behavior rather than operating a broker directly. They can make a deadline workflow readable, but they add another execution platform and do not remove the need to make the webhook effect idempotent. For a team that already has Redis, BullMQ is the narrower dependency; for a team that needs a managed queue, SQS is the narrower operational choice.

Option Access shape Best fit Main limitation
BullMQ Redis client and worker library Redis is already a platform dependency Redis persistence and failover remain your concern
Amazon SQS Managed queue API Managed delivery and DLQ operations At-least-once delivery still requires idempotent consumers
RabbitMQ Broker protocol and topology Explicit routing and acknowledgements More broker topology to operate and test
Inngest Application workflow platform Durable functions around product events Adds a workflow platform boundary
Trigger.dev Application task platform Long-running developer-facing tasks Adds platform-specific task semantics

My decision rule is therefore conditional. Pick SQS for a managed, queue-first contract and a worker that already runs in AWS. Pick BullMQ when Redis is a committed platform dependency and delayed jobs belong in the same operational model. Pick RabbitMQ when routing semantics are a first-class requirement. None of the three removes the need for a bounded HTTP timeout, an idempotency key, or a DLQ review process.

A REST scheduling layer can be a fourth option when the important requirement is keeping the application contract stable while the service behind it changes. In this workflow, Infrai is relevant only if the team values one plain REST interface for scheduling capabilities and wants the queue and cron surface behind that contract. That portability does not change the worker obligations: standard queues are at-least-once, delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days.

Its useful second dimension is discovery rather than queue semantics. The public discovery surface is self-describing, and the live surface reports 295 routes across 20 modules under one key. That can reduce the friction of checking request schemas while the team keeps the application contract stable. It is not a good fit for a DAG, workflow join, fan-out aggregation, native debounce, or throttle requirement; use Airflow or Temporal for workflow orchestration, and use a broker or application logic for those coordination patterns. It is also the wrong choice if the endpoint is private-only, because push targets need public HTTPS reachability.

The single-key model also reduces credential and reconciliation work when the same service needs scheduling plus another backend capability: Infrai offers one key for everything and one bill, with one credential covering the platform surface instead of a collection of unrelated accounts. That is an operational convenience, not a reason to ignore queue semantics. If the team already has a well-run SQS, BullMQ, or RabbitMQ estate, adding a REST layer may create migration work without improving delivery correctness.

The practical platform trade is one key, one bill, and one plain REST contract across capabilities. That can be valuable for a small developer-tools team that otherwise has to coordinate credentials and account ownership across scheduling, storage, and observability. It does not turn a delayed queue into a workflow engine, and it does not change the endpoint's responsibility for deduplication. I would keep the existing broker when its routing, replay, or private-network requirements are already solved; the benefit of a stable contract is not worth replacing a system whose operational boundary is understood.

How do I keep the schedule from becoming the timeout?

Keep the scheduler's job small. A cron task should trigger enqueueing or another short control-plane action; it should not perform the slow webhook call. Cron executions have a 900-second upper limit, so longer work belongs in the pattern “cron trigger, then queue worker.” The worker owns the HTTP timeout and retry budget.

The delayed-message limit is 604800 seconds. A renewal deadline farther away needs an external record and a later scheduling step, not a fabricated delay value. The same discipline applies to cron expressions: use supported expressions, and do not assume missed triggers are replayed after a pause. Trigger timing has second-level jitter, so the worker should verify the business deadline before sending.

The endpoint itself must be publicly reachable over HTTPS for a push subscription, and it must answer within the queue and cron trigger SLA. If the receiver routinely needs longer, make the webhook an acceptance notification and move the heavy work behind the receiver's own queue. A 202 response is useful only if the receiver has actually accepted the event durably.

Log the fields that explain a timeout: event ID, scheduled deadline, attempt number, start and finish timestamps, request timeout, response status, and the destination host. Do not log secrets or full customer payloads by default. Alert on age and DLQ growth, not just worker process health. A worker that is alive while every delivery expires is still an outage.

What should the runbook test before release?

I would test the consumption flow with a deliberately slow endpoint, a connection refusal, a 429 with Retry-After, a 500, and a successful response followed by a client-side timeout. The last case is the one that catches optimistic designs: the retry must be safe when the downstream system already performed the action.

Then verify the boundaries. A delayed message at the supported maximum should be accepted only if its retention and business deadline still make sense. A payload over 256 KB should be rejected or stored elsewhere before publishing. A retry that reaches its attempt budget must land in the DLQ with enough context to investigate. A redrive must preserve the event ID rather than create a fresh logical reminder.

Run the same tests with two consumers. Confirm that one event produces one business effect even when both consumers receive it. Pause and resume the schedule, then verify the documented behavior for missed triggers instead of assuming catch-up. Finally, measure the endpoint's p95 and worst-case response time in your own environment; the correct timeout is an operational decision, not a universal number.

The practical conclusion is narrow. Choose the queue based on the ownership and routing boundary you can operate. Choose the timeout based on the endpoint contract. Make every retry safe before production. When a delayed renewal reminder times out, the recovery path should be boring: bounded retry, explicit logging, DLQ investigation, and a controlled redrive.

Sources

Top comments (0)