Client-Side Token Bucket for SDKs

The limiter inside an SDK runs the same arithmetic as the server’s, with one difference: instead of deciding whether to admit a request, it decides when to send one. Getting the sizing and the clock right is most of the work, and it turns the reactive handling described across SDK and client-side throttling into requests that simply never get rejected.

The problem in concrete numbers

Your SDK wraps an API published at 100 requests per minute with a burst of 20. A customer’s import script calls it in a tight loop for 5,000 records. Without local pacing: 20 succeed immediately, the next 4,980 arrive in the same second, roughly 4,900 come back as 429, the SDK’s retry layer schedules them all, and the import finishes in 52 minutes having made 9,900 HTTP requests.

With a local bucket sized at 90 per minute and a burst of 18: the SDK releases requests at 1.5 per second, every one succeeds, the import finishes in about 55 minutes having made exactly 5,000 requests. Marginally slower, half the traffic, and no error path in the customer’s code.

Cost of the same import with and without local pacing Without a client bucket the import makes almost twice as many HTTP requests and surfaces thousands of rejections; with a client bucket it makes exactly one request per record in a similar wall-clock time. 5,000 records through the same API no local bucket 9,900 HTTP requests 4,900 rejections handled 52 minutes error paths on every call site local bucket at 90% of the limit 5,000 HTTP requests 0 rejections in the steady state 55 minutes one code path, predictable progress the server-side ceiling is identical; only the amount of wasted work differs

Sizing the local bucket

Parameter Rule Example (100/min, burst 20)
refillPerSec 0.9 × published rate 1.5/s
capacity 0.9 × published burst, at least 1 18
Why 90% Network jitter bunches arrivals; the server counts arrival time, not send time
Per credential One bucket per API key, never one per process keyed by key hash
Per host One bucket per API host api.example.com
Refill clock Monotonic where available performance.now() / time.monotonic()

The 10% headroom is not superstition. Requests sent 600 ms apart can arrive 40 ms apart after a mobile network buffers them, and the server measures arrivals. Pacing at exactly the published rate produces a steady trickle of rejections; pacing at 90% produces none, and costs a tenth of the throughput you were probably not using anyway.

Step-by-step implementation

typescript
// bucket.ts — the pacing primitive an SDK schedules against.
export interface BucketOptions {
  ratePerSec: number;          // 0.9 x published rate
  capacity: number;            // 0.9 x published burst
  maxWaitMs?: number;          // reject rather than queue forever
}

const now = () =>
  typeof performance !== "undefined" ? performance.now() : Number(process.hrtime.bigint() / 1_000_000n);

export class TokenBucket {
  private tokens: number;
  private last = now();
  private baseRate: number;
  private rate: number;
  private pausedUntil = 0;
  private waiters = 0;

  constructor(private opts: BucketOptions) {
    this.tokens = opts.capacity;
    this.baseRate = opts.ratePerSec;
    this.rate = opts.ratePerSec;
  }

  private refill() {
    const t = now();
    // Monotonic source + clamp: a clock correction can never mint tokens.
    const elapsed = Math.max(0, t - this.last);
    this.tokens = Math.min(this.opts.capacity, this.tokens + (elapsed / 1000) * this.rate);
    this.last = t;
  }

  /** Resolves when a token has been taken; rejects if the wait exceeds maxWaitMs. */
  async acquire(cost = 1): Promise<void> {
    const deadline = now() + (this.opts.maxWaitMs ?? 30_000);
    this.waiters += 1;
    try {
      for (;;) {
        const t = now();
        if (t < this.pausedUntil) {                    // the server told us to wait
          await sleep(Math.min(this.pausedUntil - t, deadline - t));
        }
        this.refill();
        if (this.tokens >= cost) { this.tokens -= cost; return; }
        const needMs = ((cost - this.tokens) / this.rate) * 1000;
        if (now() + needMs > deadline) throw new Error("rate_limit_wait_exceeded");
        await sleep(Math.min(needMs, 250));            // re-check periodically
      }
    } finally { this.waiters -= 1; }
  }

  /** The server publishes the truth; trust it over the local estimate. */
  syncFromHeaders(headers: { get(name: string): string | null }) {
    const remaining = Number(headers.get("X-RateLimit-Remaining") ?? NaN);
    if (Number.isFinite(remaining)) {
      // Never increase our own optimism: take the smaller of the two views.
      this.tokens = Math.min(this.tokens, remaining);
    }
    const reset = Number(headers.get("X-RateLimit-Reset") ?? NaN);
    if (Number.isFinite(reset) && remaining === 0) this.pausedUntil = now() + reset * 1000;
  }

  onRejected(retryAfterSeconds?: number) {
    this.rate = Math.max(this.baseRate * 0.1, this.rate * 0.5);   // multiplicative decrease
    this.tokens = 0;
    if (retryAfterSeconds && retryAfterSeconds > 0) {
      this.pausedUntil = now() + retryAfterSeconds * 1000;
    }
  }

  onCleanWindow() { this.rate = Math.min(this.baseRate, this.rate * 1.1); }

  get state() { return { tokens: Math.floor(this.tokens), rate: this.rate, waiters: this.waiters }; }
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
Reconciling the local estimate with the server's published remaining count The local bucket estimates twelve tokens left while the response header reports four, so the client takes the smaller value; taking the larger would let another process's usage go unnoticed until a rejection. Two views of the same budget local estimate: 12 tokens header says: 4 remaining take the minimum: 4 the gap is another process, another tab, or another machine sharing the key

Gotchas and edge cases

  • Date.now() for refill. A system clock correction of a few seconds mints or destroys tokens. Use a monotonic source and clamp elapsed time at zero.
  • One bucket per process, many credentials. A multi-tenant worker sharing one bucket lets one tenant’s batch job throttle every other tenant. Key the bucket by credential.
  • Trusting the local estimate over the server. Other processes share the key. When X-RateLimit-Remaining disagrees with your estimate, take the smaller number.
  • Timers that keep a process alive. A pending setTimeout in a CLI tool prevents Node from exiting. Use unref() on the timer, or track and clear pending waits on shutdown.
  • Suspended mobile processes. After a resume, elapsed monotonic time may be enormous or the timer may fire late in a batch. Re-check the staleness of queued work before firing it.
  • Bucket state lost on every request. Constructing the SDK client per call resets the bucket each time, which is the same as having no bucket. Document that the client is long-lived.
  • Pacing that hides an outage. If every request is queued behind a paused bucket, surface that state rather than looking indefinitely busy.
Local rate over time under multiplicative decrease and additive recovery The local rate sits at the configured value until a rejection halves it, holds while further rejections arrive, then climbs back in ten per cent steps over successive clean windows until it reaches the configured rate again. How the local rate reacts to the server configured rate 429: halve clean windows: +10% each fast down, slow up — the control law that keeps congested systems stable

Verification and testing

typescript
// bucket.test.ts — deterministic, with an injectable clock.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TokenBucket } from "./bucket";

beforeEach(() => vi.useFakeTimers());

it("releases the burst immediately, then paces", async () => {
  const b = new TokenBucket({ ratePerSec: 1.5, capacity: 18 });
  const sent: number[] = [];
  const run = async () => { for (let i = 0; i < 22; i += 1) { await b.acquire(); sent.push(Date.now()); } };
  const p = run();
  await vi.advanceTimersByTimeAsync(0);
  expect(sent.length).toBe(18);                       // the whole burst, at once
  await vi.advanceTimersByTimeAsync(3000);            // 4.5 tokens refilled
  expect(sent.length).toBe(22);
  await p;
});

it("never mints tokens when the wall clock jumps", async () => {
  const b = new TokenBucket({ ratePerSec: 1, capacity: 5 });
  for (let i = 0; i < 5; i += 1) await b.acquire();
  vi.setSystemTime(Date.now() - 60_000);              // clock steps backwards
  expect(b.state.tokens).toBe(0);                     // monotonic source is unaffected
});

it("adopts the server's remaining count when it is lower", () => {
  const b = new TokenBucket({ ratePerSec: 10, capacity: 20 });
  b.syncFromHeaders(new Headers({ "X-RateLimit-Remaining": "3" }));
  expect(b.state.tokens).toBe(3);
});

it("halves the rate on rejection and recovers on a clean window", () => {
  const b = new TokenBucket({ ratePerSec: 10, capacity: 20 });
  b.onRejected(2);
  expect(b.state.rate).toBe(5);
  b.onCleanWindow();
  expect(b.state.rate).toBeCloseTo(5.5, 5);
});

Run the same suite against a fake server that returns 429 at a known threshold, and assert the end-to-end property that matters: over a long run, the SDK’s rejection count stays at zero while its throughput stays within 10% of the published limit.

Frequently Asked Questions

Should the client bucket match the server's limit exactly?

No — size it at about 90% of the published rate and burst. The server measures arrival times, and network buffering bunches requests that were sent evenly, so pacing at exactly the limit produces a steady trickle of rejections for no gain in useful throughput.

What if the API publishes no limit?

Start deliberately low — a few requests per second with a small burst — make it configurable, and adapt from the responses you get. Treat 429, 503, and repeated connection resets as evidence of an unstated limit, and raise the default only with measurements.

How should the bucket handle multiple processes sharing a key?

Either divide the configured rate by the number of processes, or coordinate through a shared store if you already run one. In both cases, always reconcile downward from X-RateLimit-Remaining, which is the only view that accounts for every process at once.

Does local pacing remove the need for retry logic?

No. It removes the *routine* rejections, but a limit can change, another process can consume the budget, and a server can reject for reasons unrelated to your pacing. Keep one retry layer, and make sure it is the only one — pacing plus two retry layers multiplies requests instead of reducing them.