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.
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.
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');
});
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();
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
});
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
}
You have 100 images to upload. Uploading all at once freezes the browser.
p-limit// p-limit: Concurrency control
const limit = pLimit(5);
const uploads = images.map(img => limit(() => upload(img)));
await Promise.all(uploads);
You are calling a free API that allows 10 requests per second. You must not exceed this.
p-throttle// p-throttle: Rate control
const throttle = pThrottle({ limit: 10, interval: 1000 });
const requests = ids.map(id => throttle(() => api.get(id)));
await Promise.all(requests);
You are maintaining an older Node.js service using callbacks and need a leaky bucket algorithm.
limiter// limiter: Algorithm control
const limiter = new RateLimiter({ tokensPerInterval: 5, interval: "second" });
limiter.removeTokens(1, (err) => {
if (!err) next(); // Proceed to handler
});
While they differ in mechanism, these packages share common goals and traits.
// All prevent this:
// while(true) { fetch('/api') } // β Crashes or gets banned
// All handle queuing internally:
// Task 1 runs -> Task 2 waits -> Task 3 waits
// All turn chaotic calls into ordered flow:
// await controlledTask(); // Predictable timing
| Feature | p-limit | p-throttle | limiter |
|---|---|---|---|
| Primary Goal | Concurrency Control | Time-based Rate Limit | Token Bucket Algorithm |
| Input Config | Integer (count) | Object (limit, interval) | Object (tokens, interval) |
| Async Style | Promise-native | Promise-native | Callback-based |
| Best For | Parallel batches | API rate limits | Server-side logic |
| Queue Style | Wait for slot | Wait for time | Wait for token |
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.
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.
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.
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.
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.
| Choose | When you need |
|---|---|
RateLimiter | A maximum token count per interval, with continuous refill underneath |
TokenBucket | Separate burst capacity and refill rate, optionally shared through parent buckets |
npm install limiter
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.
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.
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 | Result |
|---|---|
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.
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.
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:
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.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.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.
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.
MIT.