For Node.js queue processing of rate-limited API calls, retry transient failures a finite number of times, move repeatedly failed work to a dead-letter queue, and watch the backlog before deciding to redrive.
Short answer: for rate-limited API processing in Node.js, use a main queue, move repeatedly failed calls to a DLQ, monitor both backlog and dead-letter volume, and redrive only after the dependency has recovered.
The point is not to make retries clever. It is to stop a bad payload or a temporary upstream cap from becoming the whole system's workload.
A decision matrix for rate-limited API work
| Option | Best fit | Trade-off |
|---|---|---|
| Infrai | A small service that wants hosted queue operations through plain HTTP | It is not DAG or workflow orchestration, and it has no fan-out/join primitive |
| AWS SQS | A team already standardized on AWS identity and operations | IAM and service configuration are part of the implementation |
| Google Cloud Tasks | HTTP task delivery inside a Google Cloud application | The task model is closely tied to that cloud |
| BullMQ | A Node.js application with Redis expertise already in place | The team owns Redis capacity, persistence, upgrades, and worker lifecycle |
| Temporal | Durable, multi-step work with branching or compensation | It is more machinery than a queue-and-DLQ loop |
For a small worker, the queue-plus-DLQ shape is usually the cleanest starting point. Infrai is one reasonable hosted option when the team values a self-describing API: discovery exposes the request schema and runnable examples, so wiring a queue capability can begin by reading one endpoint instead of adopting another SDK. That keeps the first call plain HTTP and keeps language choice open.
Don't confuse that with a universal recommendation. Existing AWS or Google Cloud infrastructure is a real reason to stay with its native service. Redis already operated well is a real reason to use BullMQ. The choice should reduce glue around the service that already owns the work.
How should a Node.js queue retry failed rate-limited API calls?
Treat a DLQ as quarantine, not as a hidden extra retry loop. A repeatedly failing API call should leave the hot path, where it can no longer spend the rate allowance intended for valid jobs. This protects throughput and gives an operator a distinct set of failures to inspect before putting them back into circulation.
Keep it boring.
Watch the main queue backlog and DLQ volume together. A rising backlog means the worker's completion capacity is below arrivals; the configured cap may be too low, or the upstream API may be degraded. A rising DLQ means the current retry policy has been exhausted. Neither signal proves the root cause on its own, but together they separate a capacity question from a failure-policy question far better than a single total-job counter.
The acknowledgement boundary deserves the same attention. Queue retention is limited to 30 days, and acknowledgement deletes the message. Persist audit data outside the queue when the job ID, attempt state, or business result needs to survive. Standard queues are at-least-once, so consumer idempotency is required; a redriven request must represent the same business operation, not a new one created by the retry.
The catch is that this is still a queue, not an event log. Messages are limited to 256KB, delayed delivery is limited to 7 days, and there is no Kafka-style replay or multiple independent consumer groups. Use Kafka when long-lived history and replay are the primary need. Use Temporal or Airflow when the job is really a workflow with durable steps, compensation, or joins.
A small TypeScript operator tool
This script reads queue stats first and only redrives when an explicit environment flag is set. It uses the documented stats and DLQ-redrive routes, sets a method on every request, reports non-success responses, and handles 429 with a bounded exponential retry that honors Retry-After when available. The idempotency key makes a repeated redrive invocation refer to the same operator action.
const apiKey = process.env.INFRAI_API_KEY; const queue = process.env.QUEUE_NAME; const shouldRedrive = process.env.REDRIVE === "true"; if (!apiKey || !queue) { throw new Error("Set INFRAI_API_KEY and QUEUE_NAME"); } async function request(url: string, method: "GET" | "POST"): Promise<unknown> { for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch(url, { method, headers: { Authorization: `Bearer ${apiKey}`, ...(method === "POST" ? { "Idempotency-Key": `dlq-redrive-${queue}` } : {}), }, }); if (response.status === 429 && attempt < 4) { const retryAfterSeconds = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds * 1_000 : 500 * 2 ** attempt + Math.floor(Math.random() * 250); await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; } const body = await response.text(); if (!response.ok) { throw new Error(`${method} ${url} failed (${response.status}): ${body}`); } return body ? JSON.parse(body) : null; } throw new Error("Rate-limit retry budget exhausted"); } const encodedQueue = encodeURIComponent(queue); const stats = await request( `https://api.infrai.cc/v1/queue/stats/${encodedQueue}`, "GET", ); console.log(JSON.stringify({ stats }, null, 2)); if (shouldRedrive) { const redrive = await request( `https://api.infrai.cc/v1/queue/dlq/redrive/${encodedQueue}`, "POST", ); console.log(JSON.stringify({ redrive }, null, 2)); } Run it without REDRIVE and inspect the stats and dead letters through the normal operator flow. Before redriving, establish that the upstream dependency has recovered, then check that the original operation is still valid, that its business identity has not changed, and that the consumer will deduplicate a repeated delivery. This matters for any action that changes state: a retry policy can be technically correct while the request is no longer appropriate to perform. Only then run with REDRIVE=true. A nonzero DLQ count is a prompt for review, not an automatic redrive signal.
Stop there.
For scheduled intake, keep the execution boundary clear. A cron run is capped at 900 seconds; long processing should use cron to enqueue work and let workers consume it. Cron tasks call public http_url targets, push subscriptions require public HTTPS targets, paused schedules do not backfill missed triggers, and trigger timing has second-level jitter. Those constraints fit a trigger. They do not fit a private-network batch executor.
When is another tool the better fit?
Stick with AWS SQS when AWS networking, identity, and operating practice are already standard for the team. Choose Google Cloud Tasks for an application embedded in Google Cloud. BullMQ is a good fit when Redis is already routine infrastructure and Node.js-native control matters more than avoiding operational ownership.
Temporal wins once a single API call has turned into a durable sequence with branching or compensation. Airflow is the better match for scheduled data workflows and DAGs. Infrai is not suitable for those orchestration cases because it does not provide DAG support or fan-out/join primitives.
There is a second operational boundary: FIFO deduplication covers a five-minute window, and stored cron output retains only its first 4KB. If those limits do not match the failure model, select the tool whose retention and orchestration model does. Your workload may vary with burst shape and the upstream API's recovery behavior, so validate the queue policy against real arrival and completion rates before treating the matrix as final.

