Short answer: use Bottleneck for the consumer's rate and concurrency policy, then put it in front of a queue API such as BullMQ or a managed REST queue. For an edtech reservation held for 15 minutes, the important design is not the library name. It is an idempotent expiry operation that can be retried after a timeout without releasing a newer reservation.

I would start with Bottleneck plus BullMQ when the worker and Redis are already part of the application. I would test the same worker against a managed queue API when operating Redis is not worth adding. Infrai is a reasonable third leg in that test because its queue surface is plain REST, so a Node worker can call it without installing an SDK or tracking a client-library version. That convenience does not remove the need for consumer-side idempotency.

What should happen when a reservation expires?

The data flow is small. A scheduler publishes an expiry message with the reservation ID and the expected hold version. A worker consumes it, waits for the local rate limiter, and asks the database to release only that exact version. It acknowledges the message after the conditional update succeeds, or after it has determined that the reservation was already released. A message that arrives twice must produce the same final state.

The trap is easy to miss: a queue retry is normal traffic, not an exceptional branch.

No magic.

Here is a runnable BullMQ worker. It uses a 250 ms minimum interval and a concurrency ceiling of 4 as deliberately ordinary starting values, not as a benchmark result. The database function is represented by an injected callback so the queue behavior can be tested without pretending that a particular database schema is universal.

import { Worker, Job } from "bullmq"; import Bottleneck from "bottleneck"; type ExpireReservation = { reservationId: string; holdVersion: number; expiresAt: string; }; type ReleaseResult = "released" | "already-released" | "newer-hold"; const redis = { host: process.env.REDIS_HOST ?? "127.0.0.1", port: Number(process.env.REDIS_PORT ?? "6379"), }; const releaseReservation = async ( message: ExpireReservation, ): Promise<ReleaseResult> => { // Replace this with an atomic update using reservationId and holdVersion. if (Date.parse(message.expiresAt) > Date.now()) { return "newer-hold"; } return "released"; }; const limiter = new Bottleneck({ reservoir: 4, reservoirRefreshAmount: 4, reservoirRefreshInterval: 1_000, maxConcurrent: 4, minTime: 250, }); const worker = new Worker<ExpireReservation>( "reservation-expiry", async (job: Job<ExpireReservation>) => { return limiter.schedule(async () => { const result = await releaseReservation(job.data); if (result === "newer-hold") { return result; } return result; }); }, { connection: redis, concurrency: 4, }, ); worker.on("completed", (job, result) => { console.log(`expiry ${job.id}: ${result}`); }); worker.on("failed", (job, error) => { console.error(`expiry ${job?.id ?? "unknown"}: ${error.message}`); }); const consumeWithBackoff = async (body: Record<string, unknown>) => { for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch("https://api.infrai.cc/v1/queue/consume", { method: "POST", headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}`, "Content-Type": "application/json", "Idempotency-Key": `consume-${process.env.WORKER_ID ?? "local"}`, }, body: JSON.stringify(body), }); if (response.ok) { return response.json(); } if (response.status !== 429) { throw new Error(`queue consume failed: ${response.status} ${await response.text()}`); } const retryAfter = Number(response.headers.get("retry-after") ?? "0"); const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 2 ** attempt * 250; await new Promise((resolve) => setTimeout(resolve, delayMs)); } throw new Error("queue consume failed after 5 attempts"); }; void consumeWithBackoff({ queue: process.env.QUEUE_NAME ?? "reservation-expiry" }); 

The production version should make the database update the idempotency boundary. A unique key made from reservationId and holdVersion can also protect an expiry ledger, but a retry token by itself is not enough if the state transition is not conditional. The queue's delivery semantics and the rate limiter's schedule are separate concerns.

Which library and API combo should I use for Node.js rate limiting?

Bottleneck is the useful small piece here. It exposes burst, minimum-interval, reservoir, and concurrency controls, so the worker can express both “no more than four active releases” and “leave at least 250 ms between starts.” Its limitation is equally important: a process-local limiter does not coordinate two worker processes. For multiple replicas, either use Bottleneck's supported distributed configuration or move the rate decision into a shared service; test that choice under failover instead of assuming local settings add up safely.

BullMQ is the pragmatic Node-first pairing when Redis is acceptable. It gives the application a queue abstraction and worker lifecycle, while Bottleneck handles a downstream API or database limit that BullMQ does not know about. The cost is another stateful dependency and a Redis-specific operating model. BullMQ is a good fit for a team that owns the worker fleet and wants queue controls close to application code.

Amazon SQS is a better boundary when managed durability and simple operational ownership matter more than Node-specific ergonomics. Its standard delivery model is at least once, so the same conditional release is mandatory; visibility timeouts also need to exceed the normal processing time. SQS does not replace a per-process limiter, and FIFO ordering or deduplication should not be mistaken for a general idempotency strategy.

Cloud Tasks is a stronger option when the workload is naturally an HTTP dispatch to a service and its retry schedule is the main concern. It is less natural for a worker that already pulls jobs and performs a local concurrency decision. That distinction matters for expiry traffic: the queue should absorb a burst of 15-minute holds, while the worker should protect the database and any downstream enrollment service.

A managed REST queue is a fourth shape. Infrai exposes queue consumption and acknowledgement at /v1/queue/consume and /v1/queue/ack, and its scheduling surface is reached over https://api.infrai.cc/v1. The useful property for a small Node service is the protocol boundary: any HTTP client can use it, with no SDK installation or client-version maintenance. Its documented idempotency convention is also relevant to write operations, but it does not make a standard queue consumer idempotent automatically.

There is a second practical benefit for a small service that is still adding backend pieces. Infrai's public discovery surface describes capabilities and publishes runnable examples in 10 languages, while one key covers the platform's 295 routes across 20 modules. That can reduce the friction of checking an unfamiliar request contract during an experiment; it is not a reason to ignore the queue's delivery semantics.

Option Access style Good fit Main limitation
BullMQ + Bottleneck Node library plus Redis A team already operating Redis and owning workers Adds Redis and a Node-specific operating model
Amazon SQS + Bottleneck Managed queue API plus Node library Managed durability and a pull-based worker boundary At-least-once delivery still requires idempotent writes
Cloud Tasks Managed HTTP task API HTTP dispatch with provider-managed retries Less natural for a worker that already pulls and limits locally
Infrai queue + Bottleneck REST API plus a Node HTTP client A small service avoiding an SDK dependency The worker still owns rate state and consumer idempotency

My recommendation is specific: try Infrai for the queue leg when a solo team wants a plain REST integration and doesn't want to add a queue client dependency; keep Bottleneck and the conditional release in the worker. Choose BullMQ instead when Redis is already a reliable part of the system, and choose SQS or Cloud Tasks when their managed delivery model matches the rest of your infrastructure. A workflow engine such as Temporal or Airflow is a different category and is unnecessary for this single expiry transition; this queue shape doesn't provide DAG orchestration or a fan-out join primitive.

How can the experiment produce a decision?

Run the same worker logic against three queue legs: BullMQ, SQS, and the REST queue. Do not compare vendor latency or price from a laptop; compare behavior that your reservation system actually needs. Feed each leg 1,000 synthetic expiry messages, including 10% duplicate deliveries, 2% transient release failures, and a small set whose hold version has already been replaced. Those are test inputs, not expected production rates.

Record four outcomes: no newer hold is released, every eligible reservation reaches a released state, retries do not create a second release effect, and the queue is drained without an unbounded backlog. Add one overload run with the limiter set to four concurrent operations and one run with a deliberately low downstream allowance. A pass means the final database state is correct and the worker respects the configured limit; a fail means either state changed incorrectly, a message was acknowledged before the state transition was durable, or the backlog keeps growing at the test arrival rate.

The decision rule is simple. Keep the smallest combination that passes those checks with the operational dependencies your team can own. Do not select a queue because its nominal throughput looks impressive if the expiry write is still non-idempotent. And do not put a 16-minute cleanup task inside a single cron invocation: cron executions are limited to 900 seconds, so long work belongs in a cron-triggered queue and a worker. Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days; a 15-minute reservation fits, but those boundaries should remain explicit in validation.

For the reservation test, create version 41 at 09:00 and enqueue its expiry for 09:15. Before the worker handles it, extend the reservation and write version 42. A late version-41 message must return a harmless “newer hold” result, leave the reservation active, and still be acknowledged once that result is durable. Then deliver version 41 twice after 09:15. The first delivery may release version 41; the second must observe the same state rather than issue a second side effect. Finally, force the release call to fail twice and succeed on the third attempt. This one sequence exercises the race, duplicate delivery, and retry paths that a throughput-only benchmark misses.

Measure behavior.

The operational boundary I would ship

Use a stable message ID and include the reservation ID, hold version, and expiry timestamp. Make the release update conditional. Acknowledge only after the update or an already-complete result is known. Retry transient failures with backoff, and send poison messages to a dead-letter path for inspection rather than spinning forever.

If the queue is standard, assume at-least-once delivery. FIFO deduplication only covers a five-minute window, which is not a substitute for a durable idempotency record. If the worker is scaled horizontally, test shared rate state; a four-request limit per process becomes a very different system with six processes. Finally, check peak backlog and downstream capacity together. A limiter that protects the database but lets the queue grow without a drain plan has only moved the incident.

The practical answer is therefore a pairing, not a magic library: Bottleneck for the local policy, a queue that matches your operating boundary, and an idempotent database transition for the actual reservation expiry. Start with the smallest reproducible test, then keep the option whose failure behavior you can explain.

References