Node.js · Recorded experiment

Why a Concurrency Limit Still Produces HTTP 429

A Node.js experiment sends eight requests with concurrency capped at two. Five get HTTP 429. Adding a start schedule changes the result to eight HTTP 200s.

Recorded: (UTC) · Node v22.22.3 · darwin 27.0.1 / arm64

Read this article in: 简体中文

A concurrency limit controls how many requests are unfinished at the same time. It does not, by itself, set the number of requests you can start in a time window. A fast server can free a slot quickly enough for the next request to exceed its quota.

The script below sends eight requests to a local HTTP server. It allows three requests per 500 ms window and delays successful responses by 15 ms. With at most two requests in flight, five requests still receive HTTP 429. Keeping that concurrency cap and scheduling later batches produces eight HTTP 200s in both recorded runs.

Two recorded runs

The same script in two independent runs
ScenarioRun 1Run 2
Concurrency cap onlyPeak in flight: 2; HTTP 200: 3; HTTP 429: 5Peak in flight: 2; HTTP 200: 3; HTTP 429: 5
Concurrency cap plus batch spacingPeak in flight: 2; HTTP 200: 8; HTTP 429: 0Peak in flight: 2; HTTP 200: 8; HTTP 429: 0

Why the next request starts too soon

The custom limiter starts another task whenever its active count falls below two. It keeps no count of requests started during the server's current window. After a response completes, its slot is immediately available.

Once the server has accepted three requests, its quota for that window is used. The client can have a spare concurrency slot while the server has no quota left. These are separate pieces of state. In the burst case, the server accepted three requests and rejected five; the observed peak in-flight count was two.

// Scheduling condition in the custom limiter
if (active >= maximum || !waiting.length) return;
active++;
Promise.resolve().then(fn).then(resolve, reject).finally(() => {
  active--;
  drain();
});

What changed in the second run

The second scenario uses the same limiter. Requests numbered 0–2 can start immediately. Requests 3–5 wait until at least 650 ms after the scenario starts, and requests 6–7 wait until at least 1300 ms. The 650 ms spacing leaves margin around this synthetic server's 500 ms window.

All eight requests returned 200 in both rounds, with the same peak in-flight count of two. The script records each status and elapsed time, but it uses response counts to check the result. This is a scheduling demonstration, not a throughput benchmark.

const batch = Math.floor(index / 3);
const earliestStart = batch * 650;
await delay(Math.max(0, earliestStart - (performance.now() - start)));
const response = await fetch(origin, { signal: AbortSignal.timeout(5000) });

What to log when a real API returns 429

Record each attempt's start time, finish time, status and attempt number. Count retries as new attempts. Looking only at successful responses hides some of the traffic that may consume a provider's quota.

Check the provider's documented quota scope: an account, API key or shared service may have other callers. The test server sends Retry-After, but the paced scenario uses a known schedule rather than implementing a general retry policy. Do not copy its 650 ms spacing into an unrelated integration.

If your application uses p-limit, you can replace the custom scheduler while keeping this local server. That would be a new experiment. The downloadable record here does not claim to test p-limit or any remote API.

Reproduce the rejection before changing the scheduler

Run the script once with its assertions intact. It creates a fresh local server for each scenario, closes its connections afterward and repeats the comparison. The record includes peak in-flight requests, response status for every attempt and elapsed time.

When adapting it, change the quota and dispatch policy together with your assertions. A concurrency cap can protect sockets or downstream work while a separate start schedule respects a request quota. This experiment shows why checking one count cannot establish that both limits are satisfied.

Run the experiment on your machine

Use Node.js 22 or later. No dependencies need to be installed. The script uses Node built-in modules, runs two rounds and checks assertions. Choose a new record path so you do not overwrite an earlier result.

node --version
node run.mjs
node run.mjs --record my-results.json

The server and limiter are self-authored code. Requests go only to 127.0.0.1. The server uses a fixed window, not a sliding-window or token-bucket policy. No real provider, distributed quota, account-wide limiter or third-party concurrency package was tested. Scheduling depends on the local clock; a failed assertion stops the script.

Both rounds were run by the same operator on one machine. No independent reader rerun has been recorded. Save your own output when trying another environment; a failed assertion stops the script.

Check the script SHA-256

889c29182c1aa1a0b2af9e91a705ad7b1a1e25ce1e6db3804dee473b6fcec673

This digest identifies the downloadable script. Full observations are in verification.json.