p-throttle
p-throttle wraps a function so calls start no faster than a configured quota, such as five calls per second. Excess calls wait in an in-memory queue and keep their original arguments and receiver; they are not dropped. Version 8 also supports strict rolling-window enforcement, weighted calls, aborting pending work, delay observation, queue-size inspection, and temporarily bypassing the limiter. It limits start rate, not simultaneous work, retries, distributed traffic, or server-side user quotas.
An excellent small wrapper for pacing one process against an upstream quota, and version 8's weights and queue visibility cover the common cases. Do not mistake it for a concurrency pool or distributed rate limiter, and put your own admission policy in front of its unbounded queue.
Use it if
- One Node process or browser tab must pace calls to an external API without dropping queued work
- An API publishes a simple requests-per-interval or points-per-interval quota
- You need to observe queue pressure, reject pending calls with AbortSignal, or preserve method context
- You want a dependency-free wrapper with bundled TypeScript types rather than a scheduler framework
- You run CommonJS or Node below 20: version 8.1.0 is ESM-only and its package metadata requires Node 20 or newer
- You need concurrency control rather than start-rate control: a throttled async function can still have many unresolved calls; p-limit targets simultaneous work
- You enforce quotas across multiple workers, containers, or users: its queue and counters live only in one JavaScript instance with no Redis or shared storage
- You need retries, priorities, reservoir refresh, job cancellation, or queue persistence: Bottleneck offers a fuller scheduler while p-throttle intentionally stays narrow
- You cannot allow an unbounded memory queue: every excess call is retained until execution or signal abort, and queueSize only reports pressure rather than imposing a maximum
Setup reality
Installation has no peers, native build, credentials, or config file, but version 8.1.0 requires Node 20 and publishes ESM only. Use import pThrottle from 'p-throttle'; require() is not a supported entry. Browsers need WeakRef and FinalizationRegistry, with the README naming Chrome 84+, Firefox 79+, Safari 14.1+, and Edge 84+ as baselines. Both limit and interval are mandatory, interval is milliseconds, and the default algorithm uses fixed windows. That can permit a burst near one window boundary followed by another just after it; strict: true enforces the quota over any interval at a higher resource cost. Calls are queued without a built-in maximum. Watch the wrapped function's queueSize and shed, reject, or fall back before memory and latency grow unnoticed. AbortSignal rejects unresolved queued promises with signal.reason, but it cannot undo work that has already started unless your wrapped operation also receives and observes a signal. Weighted calls must return sensible positive costs that fit the quota. Setting isEnabled to false bypasses throttling for future calls and those calls do not count, so it is an operational escape hatch rather than a pause. Finally, rate and concurrency are separate: combine p-throttle with p-limit when slow operations could overlap beyond a safe connection or memory ceiling, and create one shared wrapper per quota instead of accidentally giving each request its own fresh counter.
Patterns
Limit calls per intervalthrottle-api-calls
import pThrottle from 'p-throttle';
const throttle = pThrottle({ limit: 5, interval: 1000 });
const getUser = throttle(async (id) => {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});The quota controls when calls start. Slow fetches can overlap, and failed calls still consumed a start slot.
Queue a batch while preserving result orderprocess-batch
const results = await Promise.all(
userIds.map((id) => getUser(id))
);All calls are queued immediately and Promise.all keeps input order, but a very large batch can create a very large in-memory queue.
Use strict rolling-window enforcementenforce-rolling-window
const strictThrottle = pThrottle({
limit: 10,
interval: 1000,
strict: true,
});Strict mode prevents boundary bursts across any interval, but the README calls it more resource-intensive than the default windowed algorithm.
Charge different costs by argumentweight-expensive-calls
const throttle = pThrottle({
limit: 100,
interval: 60_000,
weight: (query) => 1 + query.tables.length,
});
const runQuery = throttle((query) => api.query(query));weight is evaluated from call arguments and consumes that much quota. Keep weights positive and within the configured limit.
Reject pending work with AbortControllerabort-pending-calls
const controller = new AbortController();
const throttle = pThrottle({
limit: 2,
interval: 1000,
signal: controller.signal,
});
const task = throttle((id) => fetchItem(id));
const pending = [task(1), task(2), task(3)];
controller.abort(new Error('shutdown'));
await Promise.allSettled(pending);Aborting rejects unresolved queued promises with signal.reason. Work already started needs its own cancellation signal.
Report calls delayed by the quotaobserve-delays
const throttle = pThrottle({
limit: 20,
interval: 1000,
onDelay: (endpoint) => metrics.increment('api.throttled', { endpoint }),
});
const request = throttle((endpoint) => fetch(endpoint));onDelay receives the wrapped call's arguments. Avoid expensive or throwing monitoring code in this callback.
Reject new work when the queue is too deepshed-overloaded-queue
async function guardedGet(id) {
if (getUser.queueSize >= 100) {
throw new Error('upstream queue overloaded');
}
return getUser(id);
}queueSize is observational and can change between the check and call. It is still useful for coarse admission control in one event loop.
Fall back when accurate data is backed upuse-fallback-on-pressure
async function getPrice(sku) {
if (accuratePrice.queueSize >= 10) {
return cachedPrice(sku);
}
return accuratePrice(sku);
}The fallback avoids adding more queued calls; p-throttle itself never discards or replaces pending work.
Temporarily bypass the limiterbypass-throttling
throttledHealthCheck.isEnabled = false;
try {
await throttledHealthCheck();
} finally {
throttledHealthCheck.isEnabled = true;
}Disabled future calls execute without waiting and do not count toward thresholds. This does not pause or drain the existing queue.
Throttle an object method with its receiverpreserve-method-context
const client = {
token: 'secret',
async load(path) { return fetch(path, { headers: { Authorization: this.token } }); },
};
client.load = throttle(client.load);
await client.load('/account');The wrapper preserves the call's original this value and arguments. Calling an extracted method without bind or a receiver still loses context.
Share one throttle across operationsshare-one-quota
const throttleVendor = pThrottle({ limit: 50, interval: 1000 });
export const fetchUser = throttleVendor((id) => vendor.get(`/users/${id}`));
export const fetchOrder = throttleVendor((id) => vendor.get(`/orders/${id}`));Functions wrapped by the same throttle factory share its quota. Creating a new factory per operation would create independent counters.
Limit starts and simultaneous workcombine-rate-and-concurrency
import pLimit from 'p-limit';
const concurrency = pLimit(4);
const startRate = pThrottle({ limit: 10, interval: 1000 });
const scheduledFetch = startRate((url) => concurrency(() => fetch(url)));p-throttle limits starts per interval; p-limit caps unresolved operations. The two controls solve different overload modes.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| bottleneck | npm | You need priorities, reservoirs, clustering, retries, and a richer job scheduler |
| p-limit | npm | You need to cap concurrent promise executions rather than starts per time interval |
| rate-limiter-flexible | npm | You enforce distributed or per-user quotas using Redis, databases, or other shared stores |
| limiter | npm | You want token-bucket and interval rate limiters with explicit token removal |