limiter vs p-limit vs p-throttle
Managing Async Concurrency and Rate Limiting
limiterp-limitp-throttleSimilar Packages:

Managing Async Concurrency and Rate Limiting

p-limit, p-throttle, and limiter are utilities for controlling how asynchronous tasks execute in JavaScript applications. p-limit restricts the number of promises running at the same time (concurrency). p-throttle limits how many times a function can run over a specific time period (rate limiting). limiter provides a generic token bucket algorithm for rate limiting, often used in server-side contexts. While all three manage flow, they solve different problems regarding parallelism and time-based constraints.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
limiter01,565103 kB58 days agoMIT
p-limit02,92516.1 kB04 hours agoMIT
p-throttle052222.2 kB06 days agoMIT

Controlling Async Flow: Concurrency and Rate Limiting Compared

When building robust JavaScript applications, you often need to control how asynchronous tasks execute. Running too many tasks at once can crash a browser or overload a server. Calling an API too fast can get your IP banned. The p-limit, p-throttle, and limiter packages solve these problems, but they work differently. Let's compare how they handle flow control.

πŸš€ Core Mechanism: Concurrency vs Time vs Tokens

p-limit focuses on concurrency. It ensures only a specific number of promises run at the same time. Once one finishes, the next in line starts.

import pLimit from 'p-limit';

const limit = pLimit(2); // Only 2 at once

const jobs = [
  limit(() => fetch('/api/1')),
  limit(() => fetch('/api/2')),
  limit(() => fetch('/api/3')) // Waits for slot
];

await Promise.all(jobs);

p-throttle focuses on time-based rate limiting. It ensures a function only runs a certain number of times within a time window.

import pThrottle from 'p-throttle';

const throttle = pThrottle({ limit: 2, interval: 1000 }); // 2 per second

const jobs = [
  throttle(() => fetch('/api/1')),
  throttle(() => fetch('/api/2')),
  throttle(() => fetch('/api/3')) // Waits for time window
];

await Promise.all(jobs);

limiter focuses on token buckets. It removes tokens from a bucket that refills over time. If no tokens are available, you wait or fail.

const { RateLimiter } = require('limiter');

const limiter = new RateLimiter({ tokensPerInterval: 2, interval: "second" });

limiter.removeTokens(1, (err, remaining) => {
  if (err) return;
  // Run task only if token removed
  fetch('/api/1');
});

🧩 API Ergonomics: Promise-Native vs Callbacks

Modern JavaScript relies heavily on Promises and async/await. How each package fits into this workflow matters for code clarity.

p-limit is built for Promises. It returns a Promise that resolves when the task completes. No wrapping needed.

// p-limit: Direct Promise support
const limit = pLimit(5);
const result = await limit(() => asyncTask());

p-throttle is also built for Promises. The throttled function returns a Promise directly.

// p-throttle: Direct Promise support
const throttle = pThrottle({ limit: 1, interval: 1000 });
const result = await throttle(() => asyncTask());

limiter is primarily callback-based. To use it with async/await, you must wrap it in a Promise yourself.

// limiter: Requires Promise wrapping
const limiter = new RateLimiter({ tokensPerInterval: 1, interval: "second" });

const waitForToken = () => new Promise((resolve) => {
  limiter.removeTokens(1, resolve);
});

await waitForToken();
await asyncTask();

βš™οΈ Configuration Complexity

Simple tasks need simple config. Complex systems need fine control. Here is how much setup each requires.

p-limit requires just one number: the concurrency limit. It is the simplest option.

// p-limit: Single integer config
const limit = pLimit(10);

p-throttle requires two numbers: the limit count and the time interval in milliseconds.

// p-throttle: Limit and interval config
const throttle = pThrottle({ limit: 10, interval: 1000 });

limiter allows complex config like token bucket size, refill rate, and even leaky bucket algorithms. It is more verbose.

// limiter: Token bucket config
const limiter = new RateLimiter({
  tokensPerInterval: 10,
  interval: "second",
  fireImmediately: false
});

πŸ›‘ Error Handling and Control

Sometimes you need to stop tasks or check status. Each package handles control differently.

p-limit tracks active and pending counts. You can check how many tasks are running.

// p-limit: Check active count
console.log(limit.activeCount); // Running now
console.log(limit.pendingCount); // Waiting

p-throttle allows you to enable or disable the throttle dynamically. Useful for pausing limits.

// p-throttle: Toggle control
throttle.disable(); // Run without limit
throttle.enable(); // Re-apply limit

limiter allows you to try removing tokens without waiting. If none are available, it returns false immediately.

// limiter: Try without waiting
const hasTokens = limiter.tryRemoveTokens(1);
if (hasTokens) {
  // Run task
}

🌐 Real-World Scenarios

Scenario 1: Image Gallery Upload

You have 100 images to upload. Uploading all at once freezes the browser.

  • βœ… Best choice: p-limit
  • Why? You want 5 uploads at a time, not a specific rate per second.
// p-limit: Concurrency control
const limit = pLimit(5);
const uploads = images.map(img => limit(() => upload(img)));
await Promise.all(uploads);

Scenario 2: Public API Consumer

You are calling a free API that allows 10 requests per second. You must not exceed this.

  • βœ… Best choice: p-throttle
  • Why? You need to enforce a time-based limit strictly.
// p-throttle: Rate control
const throttle = pThrottle({ limit: 10, interval: 1000 });
const requests = ids.map(id => throttle(() => api.get(id)));
await Promise.all(requests);

Scenario 3: Legacy Server Middleware

You are maintaining an older Node.js service using callbacks and need a leaky bucket algorithm.

  • βœ… Best choice: limiter
  • Why? It supports specific algorithms and fits callback-based middleware.
// limiter: Algorithm control
const limiter = new RateLimiter({ tokensPerInterval: 5, interval: "second" });
limiter.removeTokens(1, (err) => {
  if (!err) next(); // Proceed to handler
});

🀝 Similarities: Shared Ground

While they differ in mechanism, these packages share common goals and traits.

1. πŸ›‘οΈ Protection Against Overload

  • All three prevent your application from sending too many requests too fast.
  • They protect both your client (browser memory) and server (API limits).
// All prevent this:
// while(true) { fetch('/api') } // ❌ Crashes or gets banned

2. πŸ“¦ Queue Management

  • All three queue tasks that cannot run immediately.
  • Tasks wait their turn instead of failing instantly (unless configured otherwise).
// All handle queuing internally:
// Task 1 runs -> Task 2 waits -> Task 3 waits

3. πŸ”§ Async Coordination

  • All three help coordinate asynchronous operations.
  • They make unpredictable async flow more deterministic and manageable.
// All turn chaotic calls into ordered flow:
// await controlledTask(); // Predictable timing

πŸ“Š Summary: Key Differences

Featurep-limitp-throttlelimiter
Primary GoalConcurrency ControlTime-based Rate LimitToken Bucket Algorithm
Input ConfigInteger (count)Object (limit, interval)Object (tokens, interval)
Async StylePromise-nativePromise-nativeCallback-based
Best ForParallel batchesAPI rate limitsServer-side logic
Queue StyleWait for slotWait for timeWait for token

πŸ’‘ The Big Picture

p-limit is your go-to for parallelism. Use it when you have a lot of work but limited resources (like network connections). It keeps your app smooth without slowing down unnecessarily.

p-throttle is your go-to for compliance. Use it when external rules dictate how fast you can go (like API rate limits). It keeps you safe from bans and errors.

limiter is your go-to for control. Use it when you need specific algorithms or are working in older callback-based systems. It offers depth but requires more setup.

Final Thought: For modern frontend development, prefer p-limit and p-throttle. They fit naturally into async/await code. Reach for limiter only if you need its specific algorithmic features or are working in a non-Promise environment.

How to Choose: limiter vs p-limit vs p-throttle

  • limiter:

    Choose limiter if you require a specific token bucket or leaky bucket algorithm with fine-grained control over token refilling rates. It is suitable for server-side applications or legacy systems where callback-based patterns are still in use. Be aware that it primarily uses callbacks instead of Promises, which may require extra wrapping for modern async code.

  • p-limit:

    Choose p-limit when you need to control concurrency, such as limiting simultaneous API calls to prevent browser freezing or server overload. It is ideal for scenarios where you have many tasks but only want a fixed number running at once, like uploading multiple files in parallel batches. This package is Promise-native and integrates seamlessly with async/await workflows.

  • p-throttle:

    Choose p-throttle when you need to enforce a rate limit based on time, such as respecting an API constraint of 10 requests per second. It is best for situations where tasks must be spaced out over time rather than just limited by parallel count. Like p-limit, it is designed for modern Promise-based code and offers simple enable/disable controls.

README for limiter

limiter

CI npm

Control when work starts in Node.js and browsers. limiter provides an interval rate limiter and a hierarchical token bucket, with Promise-based waiting, synchronous admission checks, and no runtime dependencies.

Use it to pace API calls, throttle messages, or budget bytes. Waiting requests run in FIFO order on each instance with one active timer, even with thousands of callers. CommonJS, ES modules, and TypeScript declarations are included.

ChooseWhen you need
RateLimiterA maximum token count per interval, with continuous refill underneath
TokenBucketSeparate burst capacity and refill rate, optionally shared through parent buckets

Install

npm install limiter

Pace work

Save as example.mjs and run node example.mjs. The first message starts immediately; subsequent messages start at least 250 ms apart.

import { RateLimiter } from "limiter";

const limiter = new RateLimiter({ tokensPerInterval: 1, interval: 250 });

await Promise.all(["first", "second", "third"].map(async (message) => {
  await limiter.removeTokens(1);
  console.log(message);
}));

For CommonJS, use const { RateLimiter, TokenBucket } = require("limiter"). Create and reuse a limiter for each resource whose budget should be shared. Each token represents an application-defined unit: a request, a message, a byte, or a fractional cost. Work starts after removeTokens() resolves.

Reject instead of waiting

tryRemoveTokens() atomically checks capacity and consumes tokens on success. This complete HTTP example applies one shared budget to all incoming requests:

import { createServer } from "node:http";
import { RateLimiter } from "limiter";

const limiter = new RateLimiter({ tokensPerInterval: 10, interval: "second" });

createServer((request, response) => {
  if (!limiter.tryRemoveTokens(1)) {
    const seconds = Math.max(1, Math.ceil(limiter.getWaitTime(1) / 1000));
    response.writeHead(429, { "Retry-After": String(seconds) });
    response.end("Too many requests\n");
    return;
  }
  response.end("Accepted\n");
}).listen(3000);

getWaitTime(count) returns an estimated delay in milliseconds without consuming tokens. On RateLimiter it accounts for both refill and the interval allowance. It returns zero when capacity is currently available. This RateLimiter helper is new in 4.1; TokenBucket.getWaitTime() is available in 4.0.

For the Promise API with immediate rejection signaling, construct a limiter with fireImmediately: true: removeTokens() resolves to -1 when denied, otherwise to the remaining bucket balance. Invalid inputs still reject the promise.

Budget bytes and bursts

A standalone bucket starts empty and refills as time passes. This example allows a 150 KiB burst after enough idle time and refills at 50 KiB per second:

import { TokenBucket } from "limiter";

const bucket = new TokenBucket({
  bucketSize: 150 * 1024,
  tokensPerInterval: 50 * 1024,
  interval: "second"
});

const chunks = [new Uint8Array(1024), new Uint8Array(2048)];
for (const chunk of chunks) {
  await bucket.removeTokens(chunk.byteLength);
  console.log(`Ready to send ${chunk.byteLength} bytes`);
}

Split any chunk larger than bucketSize before requesting tokens. To share a budget, pass another TokenBucket as parentBucket. A successful removal charges the child and every finite ancestor together; a failed attempt charges none.

API at a glance

APIResult
new RateLimiter({ tokensPerInterval, interval, fireImmediately? })A full bucket plus an interval allowance
new TokenBucket({ bucketSize, tokensPerInterval, interval, parentBucket? })An empty bucket with independent burst and refill settings
removeTokens(count)Promise<number>: wait, consume, and return remaining balance
tryRemoveTokens(count)boolean: consume immediately if possible
getWaitTime(count)Estimated milliseconds until capacity is available; never consumes tokens
RateLimiter.getTokensRemaining()Underlying bucket balance, which can exceed the current interval allowance

Intervals accept positive milliseconds or "second", "minute", "hour", and "day" (also "sec", "min", and "hr"). Token amounts may be fractional. Always handle rejected promises for invalid or oversized requests.

Additional Notes

Waiting calls to removeTokens() are processed in FIFO order on each instance, using one active timer per instance. Concurrent calls are supported. A request is charged only when it can succeed; hierarchical buckets debit the child and all parents together. Independent children sharing a parent do not have a global FIFO order. tryRemoveTokens() and fireImmediately requests do not join the waiting queue and may consume capacity ahead of waiting requests.

RateLimiter combines a continuously refilled token bucket with an interval counter. Its interval starts at construction and resets on the first removal attempt at or after the previous interval expires. It is not a rolling-window limiter: traffic around an interval boundary can exceed the configured count in a sliding window. It does not track when your asynchronous work finishes, and it does not limit simultaneous in-flight operations. Use a concurrency limiter or rolling-window algorithm separately when those are your requirements.

getTokensRemaining() reports the underlying bucket's balance (possibly fractional), not the interval counter's remaining allowance. Use getWaitTime(count) for a non-consuming cooldown estimate, or tryRemoveTokens(count) to check and consume in one step. Estimates ignore queued requests and are not reservations; other calls can change availability.

Awaiting two independent rate limiters in sequence does not atomically enforce both limits at the eventual work start: work can collect behind the second limiter. For atomic burst and sustained-rate budgets, use parent/child TokenBucket instances. Those implement token-bucket limits, not rolling windows.

Token counts and capacities must be finite, non-negative numbers at most Number.MAX_SAFE_INTEGER; fractional tokens are supported. Numeric intervals must be finite and greater than zero. Invalid input throws RangeError (an async call rejects). An oversized request returns false from tryRemoveTokens() and rejects from removeTokens(). A standalone TokenBucket starts empty; RateLimiter starts full. For compatibility, bucketSize: 0 means unlimited capacity and bypasses parents, and tokensPerInterval: 0 refills a finite bucket to capacity on every attempt. These zero values do not disable all traffic. RateLimiter with tokensPerInterval: 0 accepts only zero-token requests.

Timing uses a monotonic clock. Timers can run late when the event loop is busy; availability is rechecked after every wait. Balances use JavaScript floating-point numbers, so fractional results can have normal rounding error. State is local to the instance and is not shared across processes, workers, or machines. Configure instances at the scope of the resource you want to limit; creating a new limiter for every request defeats a shared rate limit.

Upgrading from 3.x

Version 4 fixes concurrent accounting and waiting behavior. It keeps the existing constructors, methods, CommonJS/ESM imports, and fractional-token support, but is a major release because these observable behaviors change:

  • Token counts and capacities must be finite, non-negative numbers no greater than Number.MAX_SAFE_INTEGER. Numeric intervals must be finite and positive. Invalid values now throw RangeError, or reject an asynchronous call. Validate configuration and handle rejected removeTokens() promises; do not use negative values, NaN, or Infinity as sentinels.
  • Waiting removeTokens() calls on the same instance run in FIFO order. A large request at the front can delay smaller requests behind it. Synchronous calls and fireImmediately requests can still consume capacity ahead of the queue; children sharing a parent do not have a global FIFO order.
  • Concurrent requests now respect the interval allowance and charge a hierarchy only once per successful removal. Workloads that previously exceeded their configured limits may wait longer or receive an immediate rejection signal.
  • Remaining balances retain fractional millisecond timing precision. Avoid exact equality checks on fractional balances; use tryRemoveTokens() to check whether a request can proceed immediately.

Zero-value conventions are unchanged: a standalone TokenBucket with bucketSize: 0 is unlimited and bypasses parents; tokensPerInterval: 0 refills a finite bucket on every attempt. A RateLimiter with tokensPerInterval: 0 accepts only zero-token requests. These settings are not a general off switch.

See the changelog for the release history and Additional Notes for the full timing and queue semantics.

Development

yarn install --frozen-lockfile
yarn lint:ci
yarn test
yarn prepack

CI checks the source and both module distributions. Tests cover concurrent accounting, hierarchical buckets, FIFO backlogs, timing boundaries, and invalid inputs using deterministic clocks.

License

MIT.