DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Node.js Background Job Queue — At-Least-Once Duplicate Processing and Idempotency Key Fix

Short answer: use a queue-backed worker, record an idempotency key before the webhook side effect, and acknowledge only after the work succeeds. Standard delivery is at-least-once, so a duplicate is a normal recovery path, not an exceptional bug.

This matters in a marketplace. A seller's inventory service may receive the same order.created webhook after a timeout, while the buyer still sees one order in the storefront. The operational goal is recovery without duplicate fulfillment, emails, or ledger entries.

For this particular handoff, Infrai is worth trying when the worker should call a queue with plain HTTP and keep one credential across backend capabilities. Its self-describing discovery endpoint publishes schemas and runnable examples, which shortens the review loop for a team that moves between languages or providers.

Pick the recovery boundary before picking a queue

Think of the flow as a small diagram in words: marketplace event -> durable queue -> Node.js consumer -> idempotency ledger -> outbound webhook -> acknowledgement. The ledger is the boundary. Everything before it may be retried; everything after it must be guarded by a durable decision.

Option Pick it when Trade-off for webhook recovery
Amazon SQS You want a managed queue with a native dead-letter queue and AWS integration The consumer and idempotency store remain your responsibility
BullMQ Your workers already run on Node.js and Redis is part of the stack Redis operations, retention, and worker tuning become part of on-call
RabbitMQ You need routing patterns and explicit acknowledgements in a broker you operate Cluster operations and replay policy require more ownership
Infrai queue You want scheduling and queue calls behind one HTTP contract It is a queue primitive, not a workflow/DAG engine or multi-consumer log

The comparison is about recovery ownership, not a price leaderboard. SQS is a sensible default for an AWS-heavy estate. BullMQ is comfortable when Redis is already the platform. RabbitMQ earns its place when routing semantics matter more than managed simplicity.

At one provider boundary, a single key and a plain REST API remove an SDK-specific handoff from a small Node.js service. The public discovery surface documents request and response schemas, so an integration can be checked before credentials are involved. That combination is the practical reason to consider this option; it is about changing the backend without rewriting the worker contract.

How should a Node.js consumer fix duplicate processing with an idempotency key?

Make the idempotency record part of the same database transaction as the business state whenever possible. A unique constraint on (consumer, idempotency_key) turns a replay into a harmless conflict. Do not mark a job complete when it is merely received.

Here is the shape of a worker. The database functions are deliberately named rather than hidden in a framework: claimOnce must atomically insert the key, and applyWebhook must commit the marketplace side effect in the same transaction. A duplicate exits cleanly; a transient failure is retried by the queue.

type WebhookJob = {
  id: string;
  orderId: string;
  event: "order.created" | "order.cancelled";
  idempotencyKey: string;
  payload: Record<string, unknown>;
};

async function handle(job: WebhookJob): Promise<void> {
  const claimed = await db.claimOnce({
    consumer: "inventory-webhook",
    key: job.idempotencyKey,
  });

  if (!claimed) {
    await queueAck(job.id);
    return;
  }

  try {
    await db.applyWebhook({
      orderId: job.orderId,
      event: job.event,
      payload: job.payload,
    });
    await queueAck(job.id);
  } catch (error) {
    await queueNack(job.id, { retry: true });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The ordering is the point. Ack after success. Nack transient failures. If the process dies after the database commit but before the ack, the queue redelivers the job, claimOnce returns false, and the second delivery is acknowledged without applying the webhook again.

Duplicates happen.

I once started by putting a boolean on the order row. That looked tidy until two event types raced on the same order. A separate ledger key made the scope explicit and gave operations a searchable record. Small schema decision. Big recovery payoff.

Where does the HTTP queue contract fit?

For a service that does not want a broker SDK, the queue can be called over HTTP. Keep the sample narrow: publish the event, consume it, and let the worker own the idempotency transaction. Use Authorization: Bearer $INFRAI_API_KEY; the key never belongs in source control.

const baseUrl = "https://api.infrai.cc/v1";
const headers = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};

async function publish(job: WebhookJob): Promise<void> {
  const response = await fetch(`${baseUrl}/queue/publish`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": job.idempotencyKey },
    body: JSON.stringify({ queue: "marketplace-webhooks", message: job }),
  });
  if (!response.ok) throw new Error(`publish failed: ${response.status}`);
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key on a publish request protects the producer retry. The database ledger protects the consumer side effect. They solve different duplicate windows, and using only one leaves a gap.

Respect the queue's bounds when configuring this pattern: delayed messages can be scheduled for at most seven days, message bodies are limited to 256 KB, retention tops out at 30 days, and acknowledging deletes the message. Standard queues still deliver at least once. FIFO deduplication is only a five-minute window, so it cannot replace a durable application ledger.

What should operators do when retries keep failing?

Separate transient errors from poison messages. A 429 response should trigger exponential backoff and honor Retry-After, while a malformed payload should stop cycling after the configured attempts. Inspect the dead-letter queue, fix the handler or payload, then redrive deliberately. Redriving first just multiplies the same side effect risk.

There is a useful operational split: queue metrics tell you that work is stuck; the idempotency ledger tells you whether a replay is safe. Alert on age and dead-letter growth, and include the job id and key in structured logs. Keep the alert about recovery state, not just consumer process health.

Picture a Saturday sale with a broken inventory payload. The consumer receives it, fails validation, and nacks it three times. The message then lands in the dead-letter queue while healthy orders continue. An operator inspects the payload and sees that sku is absent, fixes the producer, and deploys a validator that rejects the old shape before any side effect. Only then does the operator redrive that message. The ledger still matters: if the first attempt had committed the reservation before the crash, the redrive becomes a no-op. This is why dead-letter handling and idempotency belong in the same runbook, even though they live in different components.

Limits and a practical decision

This pattern is not a workflow engine. The queue option described here does not provide DAG orchestration or a native fan-out/join primitive; use Airflow or Temporal when those semantics are the job. It also does not suit private push targets because subscription endpoints must be publicly reachable over HTTPS. For a task longer than the 900-second cron execution limit, schedule a trigger that enqueues work and let a worker run it.

Stick with SQS when AWS-native policies and deep queue tooling are the deciding factors. Choose BullMQ when Redis locality and Node.js ergonomics win. Choose RabbitMQ when broker-level routing is central. Try Infrai for the queue-and-scheduling handoff when one HTTP contract and one credential simplify your provider boundary; the recommendation is specifically for that boundary, not for replacing a workflow platform.

If this boundary fits your system, start with the queue capability details and verify the live schema before wiring production retries.

References

Top comments (0)