Request Concurrency Limiting in Browsers
Rate and concurrency are different resources, and a client that bounds only the first can still open two hundred simultaneous requests inside its rate budget. On HTTP/1.1 the browser caps that at six per origin and the rest queue invisibly; on HTTP/2 there is no such mercy, and every request goes out at once. Bounding concurrency explicitly is the second of the three controls in SDK and client-side throttling.
The problem in concrete numbers
A dashboard renders 60 tiles, each fetching its own data. Under a rate limit of 100 requests per minute with a burst of 20, all 60 are within budget over a minute β but sent at once over HTTP/2 they arrive as 60 concurrent requests. The server accepts 20 and rejects 40 on burst, three tiles render, and the page looks broken.
Add a concurrency cap of 6 and the same 60 requests go out in ten waves of six. The rate budget is respected, no burst is exceeded, every tile renders, and total wall-clock time is barely different because the server was never going to answer 60 requests in parallel anyway.
Decision matrix: picking the cap
| Environment | Practical ceiling | Suggested cap | Reason |
|---|---|---|---|
| Browser, HTTP/1.1 | 6 per origin | 4β6 | Matches the transport; more just queues |
| Browser, HTTP/2 | ~100 streams | 6β10 | Server burst allowance is the real constraint |
| Browser, HTTP/3 | ~100 streams | 6β10 | Same; head-of-line blocking is gone, budgets are not |
| Node.js SDK | Pool size (default 5ββ) | 10β25 | Bounded by the serverβs burst, not by your CPU |
| Serverless function | Per-invocation | 2β5 | Many instances multiply concurrency invisibly |
| Batch/import tool | β | 5β10 | Throughput comes from steady pacing, not parallelism |
The number that should drive the cap is the serverβs burst allowance, not the clientβs capability. If the published burst is 20, a cap of 6 leaves headroom for retries and other tabs; a cap of 40 guarantees rejections on every page load.
Step-by-step implementation
// scheduler.ts β priority queue + semaphore + abort support.
type Priority = "user" | "background";
interface Waiter {
resolve: () => void;
reject: (e: Error) => void;
priority: Priority;
enqueuedAt: number;
signal?: AbortSignal;
}
export class ConcurrencyLimiter {
private inFlight = 0;
private queue: Waiter[] = [];
constructor(private max = 6, private maxQueue = 64, private maxWaitMs = 20_000) {}
private next() {
// User-initiated work jumps ahead of prefetches; FIFO within a priority.
const idx = this.queue.findIndex((w) => w.priority === "user");
const pick = idx >= 0 ? idx : 0;
const w = this.queue.splice(pick, 1)[0];
if (!w) return;
if (w.signal?.aborted) { w.reject(new DOMException("Aborted", "AbortError")); return this.next(); }
if (Date.now() - w.enqueuedAt > this.maxWaitMs) {
w.reject(new Error("queue_wait_exceeded")); // stale: the user has moved on
return this.next();
}
this.inFlight += 1;
w.resolve();
}
async run<T>(fn: () => Promise<T>, opts: { priority?: Priority; signal?: AbortSignal } = {}): Promise<T> {
if (this.queue.length >= this.maxQueue) throw new Error("client_queue_full");
if (this.inFlight >= this.max) {
await new Promise<void>((resolve, reject) => {
const w: Waiter = { resolve, reject, priority: opts.priority ?? "background",
enqueuedAt: Date.now(), signal: opts.signal };
this.queue.push(w);
// An abort while queued must free the queue slot immediately.
opts.signal?.addEventListener("abort", () => {
const i = this.queue.indexOf(w);
if (i >= 0) { this.queue.splice(i, 1); reject(new DOMException("Aborted", "AbortError")); }
}, { once: true });
});
} else {
this.inFlight += 1;
}
try {
return await fn();
} finally {
// Release in finally: a thrown request must not leak a slot forever.
this.inFlight -= 1;
this.next();
}
}
get state() { return { inFlight: this.inFlight, queued: this.queue.length }; }
}// transport.ts β rate first, then concurrency: both controls, one call path.
const bucket = new TokenBucket({ ratePerSec: 1.5, capacity: 18 });
const slots = new ConcurrencyLimiter(6);
export async function apiFetch(url: string, init: RequestInit & { priority?: "user" | "background" } = {}) {
await bucket.acquire(); // may we send at all, yet?
return slots.run(() => fetch(url, init), { // and is there room in flight?
priority: init.priority ?? "background",
signal: init.signal ?? undefined,
});
}Gotchas and edge cases
- Acquiring the slot before the token. A request that holds a concurrency slot while waiting for a rate token blocks five other requests that could have gone out. Take the token first.
- Slots leaked on failure. Releasing only on success means one thrown request permanently reduces your concurrency. Release in
finally, always. - Aborted requests still holding queue positions. A user navigating away should free the slot immediately; listen for the abort while queued, not only while in flight.
- Priority inversion. Without a priority, a hundred queued prefetches delay the click the user just made. Two levels β user and background β are enough.
- Serverless multiplication. A cap of 5 inside a function that runs in 200 concurrent instances is a cap of 1,000 against the API. Coordinate at the platform level or divide the cap by expected concurrency.
- Streaming responses hold the slot. A long-lived server-sent-events connection occupies a slot for its lifetime. Exclude streams from the pool or give them a separate one.
- Retries competing with new work. Retried requests should carry background priority unless the user is waiting for them, or a retry storm starves fresh interactions.
Verification and testing
// concurrency.test.ts β assert the cap, the ordering, and the release path.
import { describe, it, expect, vi } from "vitest";
import { ConcurrencyLimiter } from "./scheduler";
const deferred = () => { let r: () => void; const p = new Promise<void>((res) => (r = res as any)); return { p, r: r! }; };
it("never exceeds the configured cap", async () => {
const lim = new ConcurrencyLimiter(3);
let peak = 0, active = 0;
const gates = Array.from({ length: 10 }, deferred);
const runs = gates.map((g) => lim.run(async () => {
active += 1; peak = Math.max(peak, active); await g.p; active -= 1;
}));
await Promise.resolve();
expect(peak).toBe(3);
gates.forEach((g) => g.r());
await Promise.all(runs);
});
it("releases the slot when the task throws", async () => {
const lim = new ConcurrencyLimiter(1);
await expect(lim.run(async () => { throw new Error("boom"); })).rejects.toThrow("boom");
await expect(lim.run(async () => "ok")).resolves.toBe("ok"); // slot was freed
});
it("serves user-priority work before background work", async () => {
const lim = new ConcurrencyLimiter(1);
const order: string[] = [];
const gate = deferred();
const blocking = lim.run(async () => { await gate.p; order.push("blocking"); });
const bg = lim.run(async () => { order.push("background"); }, { priority: "background" });
const user = lim.run(async () => { order.push("user"); }, { priority: "user" });
gate.r();
await Promise.all([blocking, bg, user]);
expect(order).toEqual(["blocking", "user", "background"]);
});
it("frees a queued slot when the caller aborts", async () => {
const lim = new ConcurrencyLimiter(1);
const gate = deferred();
const blocking = lim.run(async () => { await gate.p; });
const ac = new AbortController();
const queued = lim.run(async () => "never", { signal: ac.signal });
ac.abort();
await expect(queued).rejects.toThrow(/Abort/);
expect(lim.state.queued).toBe(0);
gate.r(); await blocking;
});In the browser, verify the real effect in DevTools: with the cap in place the network panel should show a steady staircase of six concurrent requests rather than a single wall of sixty, and the count of 429 responses on a dashboard render should be zero.
Frequently Asked Questions
Is a rate limit not enough on its own?
No. Rate bounds requests per unit time; concurrency bounds how many are in flight at once. A burst allowance of twenty can be consumed by twenty simultaneous requests in a single millisecond, which is within the rate budget and still produces rejections and a load spike on the server.
What cap should a browser client use?
Six to ten for most applications. Six matches the historical HTTP/1.1 behaviour that servers were sized for, and it leaves room within a typical burst allowance for retries and other tabs. Raise it only if measurement shows latency improving without rejections appearing.
Does HTTP/2 remove the need for a client-side cap?
It does the opposite. HTTP/1.1's six-connection limit was an accidental concurrency control that protected servers from client fan-out; HTTP/2 multiplexes freely, so a page that used to trickle sixty requests now sends them simultaneously. The cap has to become explicit.
How should long-lived streaming requests be handled?
Keep them out of the request pool. An event stream or long poll occupies a slot for minutes and would starve ordinary requests; give streams their own small pool with its own cap, and exclude them from the queue-age bound that assumes short requests.
Related
- SDK & Client-Side Throttling β the parent guide covering all three client controls.
- Client-Side Token Bucket for SDKs β the rate control this pairs with.
- Implementing Retry Queues in Axios Interceptors β where retried requests re-enter the scheduler.
- Handling 429 Too Many Requests in React β surfacing queue state in the interface.