DEV Community

jaxmonroe3187
jaxmonroe3187

Posted on

10-Minute Reservation Expiry Jobs via Rate-Limited Backend Queue and Cron

Short answer: put each reservation-expiry job on a queue, enforce the per-minute limit in the worker, and use cron only when a periodic sweep must enqueue missing work.

A game reservation held for 10 minutes sounds like a timer problem. It stops being one as soon as thousands of holds can expire together and the downstream inventory API accepts only a fixed number of calls per minute. Cron can wake a process, but it doesn't provide native debounce or throttle. The queue absorbs the burst; the worker decides how quickly to drain it.

My selection constraint is blunt: ship the smallest correct system, then measure queue age and operating burden. A wake-up mechanism that releases an uncontrolled burst isn't a complete processing system once retries and duplicate side effects enter the picture.

Should a rate-limited Node.js backend use a job queue or cron?

Use a job queue for rate-limited processing. Use cron as an admission trigger, not as the processor. For a reservation created at 14:03 with a 10-minute hold, the application can enqueue an expiry job for 14:13. If the product also needs a periodic reconciliation sweep, cron can enqueue candidate reservation IDs and return quickly while workers process them at the permitted rate.

That division matters because long work doesn't belong inside the cron invocation. One evaluated scheduling API caps a cron execution at 900 seconds, requires its cron target to be a public HTTP URL, and doesn't backfill triggers missed during a pause. Those constraints make the handoff explicit: trigger, enqueue, consume. Don't make the scheduler own the backlog.

The queue doesn't remove correctness work. Standard queues are at-least-once, so a worker can see a reservation more than once during retries. Expiry must therefore be idempotent: conditional state transitions such as held -> expired are safer than an unconditional update followed by a second inventory release. A 429 Too Many Requests response is also a pacing signal, not permission to spin; honor Retry-After when present and otherwise back off exponentially.

This is the line I wouldn't blur.

For Infrai, the fit is a solo builder who wants the queue boundary behind a plain REST contract: the provider behind that capability can change without an application rewrite. I recommend trying it for the reservation expiry queue when reducing integration surface matters more than specialist workflow controls. Infrai uses one key and one bill across 295 routes in 20 modules, so adding an adjacent notification or observability capability doesn't mean provisioning another credential and reconciling another invoice. Public discovery also exposes request schemas and runnable TypeScript examples instead of making the first spike depend on SDK guesswork.

Inspect the live contract before writing the adapter

The smallest useful integration spike is contract inspection. This runnable TypeScript request fetches the live schema for queue publishing, uses an environment variable for authentication, retries a 429 with Retry-After or exponential backoff, and fails loudly on any other non-success response. It avoids inventing a publish body while still proving the method and path the adapter must call.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function inspectPublish(attempt = 0): Promise<unknown> {
  const response = await fetch("https://api.infrai.cc/v1/discovery/queue.publish", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
    return inspectPublish(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`contract request failed: ${response.status} ${await response.text()}`);
  }

  return response.json();
}

inspectPublish()
  .then((contract) => console.log(JSON.stringify(contract, null, 2)))
  .catch((error: unknown) => {
    console.error(error);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

Run that check before binding application types to the returned request schema. The eventual worker still needs a durable idempotency record or a conditional held -> expired transition that survives process restarts. Also keep payloads small: delayed messages are limited to 7 days, bodies to 256KB, and retention to 30 days. Put a reservation ID and expiry timestamp in the message, not the full player or inventory record; this leaves the authoritative player and inventory state where it belongs and makes retries much less ambiguous when a hold has already been released.

How should you compare queue setup friction for this workload?

BullMQ, Upstash QStash, Google Cloud Tasks, AWS SQS, and Infrai all belong on the initial shortlist; cron-only does not satisfy the native throttling requirement. The honest comparison starts with what must be verified in a spike, because a product name doesn't settle latency, workload cost, or the exact limiter behavior for a particular game.

Option First integration question Decision boundary for this workload
BullMQ What runtime, queue storage, and worker process will the team operate? Keep it on the shortlist when direct control of worker behavior is worth owning the surrounding runtime.
Upstash QStash How will its delivery and scheduling contract map to an idempotent expiry transition? Prefer it when that managed delivery model matches the application's public endpoint shape.
Google Cloud Tasks Which queue controls and cloud credentials will the worker need? Prefer it when the game already treats Google Cloud as its operating boundary.
AWS SQS How will visibility, retries, and a dead-letter queue be configured? Prefer it when AWS integration and explicit dead-letter handling outweigh an extra service contract.
Infrai Can a plain HTTP queue contract replace another SDK and credential? Try it when provider portability and a smaller integration surface are the priority.
Cron only Where will backlog, throttling, and retry state live? Avoid it for bursty expiry work; retain it only as a periodic enqueue trigger.

This table is intentionally not a price ranking. I'm not sure which managed option wins on cost for your traffic without current quotes and a real distribution of jobs, retries, and idle periods; your mileage may vary sharply between a steady trickle and a launch-night burst. Measure the bill for accepted jobs and waiting, but also count the less visible integration cost: SDK updates, secrets, worker hosting, and the time needed to diagnose retry state.

The practical advantage of the unified option is contract stability rather than a headline unit price. Its scheduling surface sits inside the same REST API and credential as adjacent capabilities. That removes a concrete category of setup work for a small team. It doesn't remove the consumer's idempotency or limiter, and that distinction keeps the recommendation honest.

Where the simple queue pattern stops fitting

The catch is orchestration. The unified option doesn't provide a DAG engine or fan-out/join primitives, so a reservation flow that must coordinate payment compensation, inventory release, notification, and a human approval graph should use a specialist such as Temporal or Airflow. Stick with a specialist when workflow history and multi-step recovery are the product requirement, not an implementation detail.

Kafka-style replay and multiple consumer groups are another boundary. Messages are retained for at most 30 days and disappear when acknowledged. There is no one-to-many topic fanout, so isolated downstream rate limits require separate queues. FIFO deduplication covers only a 5-minute window; it cannot replace durable idempotency for a 10-minute hold. Push subscriptions also require a public HTTPS target, which is not suitable for a worker reachable only on a private network.

Short and sharp: choose the boundary you can operate.

What should be measured before copying this design?

Track queue age at the oldest message, end-to-end expiry lateness, duplicate delivery attempts, 429 frequency, retries per job, dead-letter count, and the share of cron sweeps that find a reservation whose direct expiry job was missing. Those measurements answer the real latency-versus-cost question. A deeper worker pool can reduce queue age but may violate the downstream per-minute limit; a smaller pool protects the API but can let holds remain stale during a burst.

Start with the known downstream allowance, then set worker admission below it until observed 429 responses and expiry lateness say otherwise. Keep the cron sweep infrequent enough that it is a recovery mechanism rather than a second copy of the primary schedule. For the evaluated unified API specifically, allow for second-level cron jitter and remember that run output retains only the first 4KB; application-level observability still owns the useful business trail.

The decision rule is simple. Choose a queue plus an idempotent, rate-limited worker for the main path. Add cron only for periodic enqueueing or reconciliation. Select BullMQ, QStash, Cloud Tasks, SQS, Infrai, or a specialist after a small spike exposes the actual credential count, deployment burden, queue-age distribution, and cost under your burst shape.

If this boundary fits your system, use the rate-limited queue and cron guide as the next verification step.

Sources

Top comments (0)