Short answer: To implement rate-limiting in Node.js message processing, use a queue service for delayed work and keep a durable per-destination token bucket beside the delivery record. The queue moves the message. The limiter decides when processing may start.

Choice Good fit Recovery trade-off
Queue plus per-destination bucket Many recipients with separate quotas More state to inspect
One global concurrency limit One downstream quota One failing recipient affects others
Scheduled database polling Low volume and simple recovery Claim and polling logic become yours

I would use the first row when the operational question is “which destination is aging?” That answer is more useful than a single queue-depth number. The cost is clear state and an atomic limiter.

How should a Node.js queue service implement rate-limiting for message processing?

The queue service owns visibility, delayed re-enqueueing, and worker movement. The application owns the delivery record, recipient policy, and idempotency decision. A timeout is an unknown result, not proof that the receiver did nothing.

For one marketplace event sent to two destinations, keep separate delivery records. The event ID identifies the business event; the delivery ID identifies one destination-specific job. Reusing one status for both would make an outage at one destination block recovery.

Keep it boring.

type WebhookDelivery = { deliveryId: string; eventId: string; destinationId: string; state: 'pending' | 'in_flight' | 'succeeded' | 'retryable' | 'dead'; attempt: number; nextAttemptAt: string; idempotencyKey: string; }; 

I first treated a delayed queue as the whole solution. That was the wrong boundary. Delay says when a job can be visible again; it does not say whether the destination has capacity then. Those are separate clocks, and collapsing them makes a rate-limited message processor hard to recover.

Suppose one worker group allows 20 attempts per second across all recipients. A failing destination can consume retry slots while a healthy destination receives new order events. A per-destination bucket isolates that failure.

For example, a policy might allow 5 attempts per second with a burst of 10. Those are inputs from the recipient contract and observed behavior, not universal defaults. The worker checks immediately before the network call. If there is no token, it reschedules without increasing the attempt count.

The limiter must be atomic. Two workers reading the last token and both spending it is a distributed race, not an unlikely corner case.

type Limit = { tokens: number; capacity: number; refillPerSecond: number; checkedAtMs: number; }; function takeToken(limit: Limit, nowMs: number): Limit | null { const elapsed = Math.max(0, nowMs - limit.checkedAtMs) / 1000; const tokens = Math.min( limit.capacity, limit.tokens + elapsed * limit.refillPerSecond, ); if (tokens < 1) return null; return { ...limit, tokens: tokens - 1, checkedAtMs: nowMs }; } 

The in-memory function describes policy only. Production storage needs a transaction or another atomic read-modify-write operation.

How do retries avoid duplicate deliveries?

Classify outcomes as accepted, temporary failure, permanent failure, or unknown result. A temporary failure gets a later attempt. A permanent failure stops automatic delivery. An unknown result may be retried, but it must reuse the same idempotency key because the receiver may already have accepted the event.

Test the awkward case directly: the sender times out after the receiver accepts the event. Run 3 attempts in the test, then verify that the receiver applies the order update once. Add 20 workers competing for a bucket with capacity 10 and assert that the atomic limiter never grants more than the configured allowance. Make the test use fake time, because wall-clock assertions are brittle and do not explain recovery.

Observe age of the oldest pending delivery, retry rate by destination, unknown-result count, and time waiting for a token. Do not log the full order payload by default.

A scheduled poller is a reasonable runner-up for low volume. Its limitations are polling, row claiming, and crash reconciliation. A global limit is simpler when every message shares one downstream quota. For the marketplace webhook case, the useful decision rule is a queue for movement, a durable record for truth, and a per-destination limiter for permission.

References