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.

Sixty tile requests under three transport and control combinations Over HTTP/1.1 the browser queues beyond six connections, over HTTP/2 all sixty leave at once and exceed the burst allowance, and with an explicit semaphore the requests leave in controlled waves regardless of transport. Sixty requests, one dashboard render HTTP/1.1, no cap browser allows 6 per origin 54 queue invisibly accidentally survives no control over priority HTTP/2, no cap 60 streams at once burst of 20 exceeded 40 rejected with 429 page renders broken explicit semaphore 6 in flight, ten waves burst never exceeded same wall-clock time and you control the order HTTP/2 removed the accidental limit that used to protect servers from client fan-out

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

typescript
// 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 }; }
}
typescript
// 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,
  });
}
Order of the two client-side controls A request first waits for a rate token, which decides whether it may be sent at all, and then for a concurrency slot, which decides whether there is room in flight; releasing the slot in a finally block admits the next waiter. Token first, slot second call site priority: user rate token may we send yet? concurrency slot room in flight? fetch finally: release the slot, admit the next waiter reversing the order holds a slot while waiting for a token, starving other requests

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.
Choosing the cap by measurement rather than by intuition Raising the cap from two to six shortens the page render because requests overlap, raising it from six to twelve barely helps because the server is the bottleneck, and raising it to thirty introduces rejections without improving latency. Raise the cap until it stops helping cap 2 render 4.1 s 0 rejections cap 6 render 1.6 s 0 rejections cap 12 render 1.5 s 0 rejections cap 30 render 1.5 s 18 rejections the knee is at six: past it you add rejections and no speed

Verification and testing

typescript
// 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.