Migrate from Bottleneck
Map Bottleneck limiters, options, job options, clustering, and events to valve.
valve and Bottleneck both queue jobs in-process and can share limits through Redis. The main differences:
- valve describes limits as rate rules (
limitperinterval) instead ofminTimeand reservoirs. - valve reads provider rate limit headers and pauses every worker.
- valve uses your Redis client, and each worker passes its own configuration. Redis stores a compatibility fingerprint and rejects workers with different policies; it does not distribute policy updates.
- Crashed workers' global concurrency slots expire automatically through
leases, without per-job
expiration. - valve has no callback API, no
Groupclass, and no runtimeupdateSettings.
There is no compatibility layer. Translate pacing and quota policies explicitly; reservoir reset times and chained reservations can change behavior.
Quick comparison
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
id: 'hubspot',
maxConcurrent: 5,
minTime: 100,
datastore: 'ioredis',
clientOptions: { host: '127.0.0.1', port: 6379 },
});
const contact = await limiter.schedule({ priority: 1 }, getContact, '123');import { Redis } from 'ioredis';
import { createValve } from 'valvejs';
import { redisStore } from 'valvejs/redis';
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
const hubspot = createValve({
id: 'hubspot',
rate: { limit: 1, interval: 100 },
concurrency: { limit: 5, scope: 'global' },
store: redisStore({ client: redis }),
});
const contact = await hubspot.run(() => getContact('123'), { priority: 4 });Note the priority: Bottleneck runs lower numbers first, valve runs higher numbers first. See Priorities.
Migration steps
Translate the limiter options
Use the option mapping to build a rate rule and a
concurrency setting for each limiter.
Replace schedule, wrap, and submit
Change every call site to run or wrap, converting priorities and weights.
See Methods.
Move Redis configuration into your code
Create the Redis client yourself and pass it to redisStore. Make sure every
worker uses the same id and the same rules.
Replace retry and event handlers
Swap failed retry handlers for wrapFetch retries or your own retry logic,
and move event listeners to the valve events.
Roll out and remove Bottleneck
The two libraries keep separate state. Stop producers, drain old workers, and wait for the provider quota to recover before admitting work through Valve. Running both against the same credential gives each an independent budget. An uninterrupted transition requires external coordination of that budget; simply starting Valve with a fresh ID does not transfer Bottleneck reservations.
Limiter options
| Bottleneck | valve |
|---|---|
id | id (required, no { or }) |
maxConcurrent (local) | concurrency: n |
maxConcurrent (clustered) | concurrency: { limit: n, scope: 'global' } |
minTime: ms | rate: { limit: 1, interval: ms } |
reservoir + reservoirRefreshAmount + reservoirRefreshInterval | rate: { limit: amount, interval, algorithm: 'fixed-window' } |
reservoir + reservoirIncreaseAmount + reservoirIncreaseInterval + reservoirIncreaseMaximum | rate: { limit: amount, interval, burst: maximum } |
highWater with strategy: OVERFLOW | maxQueue |
highWater with LEAK, OVERFLOW_PRIORITY, or BLOCK | No equivalent. maxQueue rejects the new job. |
datastore, clientOptions, clusterNodes | store: redisStore({ client }) with a client you create |
clearDatastore | No direct equivalent. Follow the coordinated quota migration procedure. |
timeout (Redis key TTL) | Not needed. Keys expire on their own. |
Promise | Not supported. valve uses native promises. |
minTime
minTime spaces job starts. A GCRA rule with burst: 1 does the same, and is
often easier to read as a rate:
// Bottleneck: { minTime: 250 }
rate: { limit: 1, interval: 250 }
// Same spacing, expressed per second:
rate: { limit: 4, interval: '1s' }Refresh intervals
A reservoir that resets to N every interval is a fixed window. One difference: Bottleneck's interval starts when the limiter is created, while valve's windows are aligned to the store's epoch clock, so every worker agrees on boundaries.
// Bottleneck: { reservoir: 100, reservoirRefreshAmount: 100, reservoirRefreshInterval: 60_000 }
rate: { limit: 100, interval: '1m', algorithm: 'fixed-window' }Bottleneck recommends adding minTime to avoid a flood when the reservoir
refreshes. In valve, add a second rule:
rate: [
{ limit: 100, interval: '1m', algorithm: 'fixed-window' },
{ limit: 5, interval: '1s' },
];Increase intervals
A reservoir that grows by N per interval up to a maximum is a token bucket.
GCRA with burst behaves the same way, refilling smoothly instead of in steps.
// Bottleneck: { reservoir: 40, reservoirIncreaseAmount: 2,
// reservoirIncreaseInterval: 1000, reservoirIncreaseMaximum: 40 }
rate: { limit: 2, interval: '1s', burst: 40 }Methods
| Bottleneck | valve |
|---|---|
schedule(fn, ...args) | run(() => fn(...args)) |
schedule(options, fn, ...args) | run(() => fn(...args), options) |
wrap(fn) | wrap(fn) |
wrapped.withOptions(options, ...args) | run(() => fn(...args), options), or a second wrap(fn, options) |
submit(fn, ...args, callback) | No callback API. Use run, or util.callbackify. |
stop({ dropWaitingJobs: true }) | close() |
stop({ dropWaitingJobs: false }) | close({ drain: true }) |
counts(), queued(), running() | stats() |
empty() | stats().queued === 0 |
on('idle') or awaiting idle | idle(), or on('idle') |
updateSettings(options) | No runtime policy mutation. Coordinate a quota migration. |
incrementReservoir, currentReservoir | No equivalent. |
chain(limiter) | Use one atomic policy with scoped rules; see below. |
jobStatus(id), jobs(status) | No equivalent. |
run passes a context, not your arguments
run(fn) calls fn({ signal }). Do not pass a function that expects its own
arguments directly. Write run(() => fn(a, b)), not run(fn).
Chaining vs Atomic Rules
Use scoped rules when one job must spend several budgets together. Bottleneck's
chain() forwards work admitted by one limiter into another limiter's queue.
Valve evaluates all configured budgets in one store operation, using the job's
identities to select scoped counters.
These examples express the same intended pacing policy: one business admission per 100 ms and one admission per recipient per six seconds. They use local stores to keep the comparison focused; the reservation guarantees differ.
import Bottleneck from 'bottleneck';
const business = new Bottleneck({ minTime: 100 });
const recipients = new Bottleneck.Group({ minTime: 6000 });
recipients.on('created', (limiter) => limiter.chain(business));
const recipientId = 'contact-42';
await recipients.key(recipientId).schedule(() =>
fetch('https://example.com/messages', {
method: 'POST',
body: JSON.stringify({ recipientId, text: 'Hello' }),
}),
);import { createValve } from 'valvejs';
const messages = createValve({
id: 'business-42',
rate: [
{ name: 'business', limit: 1, interval: '100ms' },
{ name: 'recipient', scope: 'recipient', limit: 1, interval: '6s' },
],
});
const recipientId = 'contact-42';
await messages.run(
() =>
fetch('https://example.com/messages', {
method: 'POST',
body: JSON.stringify({ recipientId, text: 'Hello' }),
}),
{ scopes: { recipient: recipientId } },
);
await messages.close();Chaining is useful when independently managed limiters and queues are the
composition you need. Group creates keyed instances, and each limiter can
have its own settings and lifecycle. But an earlier stage can reserve capacity
while a later stage waits. Reversing the chain changes which budget is reserved
first; it cannot make both reservations atomic. An outer concurrency slot may
also remain occupied while downstream work waits.
Valve grants a job only when every applicable budget allows it, so a denied
recipient spends no business quota. A1 can start, A2 can wait for recipient A,
and eligible B1 can proceed. The caller manages one Valve and supplies opaque
identities rather than managing a limiter graph. Priorities and cancellation
belong to one local queue. Nesting a.run(() => b.run(fn)) still creates
independent reservations and does not provide this guarantee.
The tradeoffs are explicit:
- Every job must supply every configured identity. Rules are static; scoped identities cannot select dynamically different limits or omit rules.
- All keys in an atomic Redis operation must share a Cluster slot. A hot business cannot spread its atomic budget over several shards.
- Scope dimensions select rate counters, not independent concurrency limits
or feedback channels. Global leases and 429/
Retry-Afterpauses apply to the whole Valve ID. - Queues remain local and non-durable. There is no cross-worker scheduling fairness. Atomic admission does not guarantee provider delivery, exact arrival spacing, or exactly-once side effects.
See Scoped rate limits for mixed algorithms and multiple dimensions, and Changing a shared quota for adding, renaming, or removing rules safely.
Job options
| Bottleneck | valve |
|---|---|
priority (0-9) | priority (any integer, higher first) |
weight | cost |
expiration | No direct equivalent. See Expiration. |
id | Not needed. Errors carry the valve's id. |
Priorities
Bottleneck's default is 5, and lower numbers run first. valve's default is 0, and higher numbers run first. To keep the same order and the same default:
const valvePriority = 5 - bottleneckPriority;Weight
weight reduces the reservoir and counts toward maxConcurrent. In valve,
cost is spent against every rate rule, but concurrency always counts one per
job. Under GCRA, cost must not exceed burst.
Expiration
Bottleneck uses expiration both to time out slow jobs and to recover running
slots from crashed workers.
- Crash recovery is automatic in valve. Global concurrency uses leases that
expire after
leaseTtlif a worker dies. - Timeouts are your function's responsibility. Use the job's signal:
await api.run(({ signal }) => {
const timeout = AbortSignal.timeout(10_000);
return fetch(url, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
});maxWait is different: it bounds time in the queue, not time running.
Retries
Bottleneck retries jobs when a failed listener returns a delay. valve has no
general retry hook.
- For rate limit responses, use
wrapFetch. It retries 429s, and 503s with a validRetry-After, after a shared pause, for GET, HEAD, OPTIONS, PUT and DELETE by default. Other methods require explicit opt-in after verifying provider idempotency. - For other failures, retry around
runwith your preferred library, or let your job queue retry.
// Bottleneck
limiter.on('failed', (error, info) => (info.retryCount < 2 ? 1000 : undefined));
// valve: rate limit retries are built into wrapFetch
const apiFetch = api.wrapFetch(fetch, { retries: 2 });Clustering
| Bottleneck behavior | valve behavior |
|---|---|
| The first limiter stores its settings in Redis. | Each worker passes its own rules. Keep them identical. |
maxConcurrent is cluster-wide. | concurrency: n is per process. Use scope: 'global' for cluster-wide. |
Set expiration so crashed workers release slots. | Leases expire after leaseTtl automatically. |
Priorities, queue order, and highWater are per limiter. | Same. Queues are local. |
idle waits for jobs running anywhere in the cluster. | idle is local to the process. |
| Bottleneck creates the Redis connection by default. | You create, connect, and close the client. |
| Timings use client clocks. | All rate math uses the Redis server's TIME. |
Groups
Bottleneck.Group creates one limiter per key. For per-key rate budgets under
one shared policy, prefer scoped rules as shown above; inactive Redis counters
expire automatically. For independent per-key concurrency limits or feedback
pauses, keep a Map of valves that share one store. See Per-tenant budgets.
// Bottleneck
const group = new Bottleneck.Group({ maxConcurrent: 2, minTime: 250 });
await group.key(`shop:${shopId}`).schedule(() => syncShop(shopId));
// valve
const valves = new Map<string, Valve>();
function valveFor(key: string) {
let valve = valves.get(key);
if (!valve) {
valve = createValve({
id: key,
rate: { limit: 4, interval: '1s' },
concurrency: 2,
store,
});
valves.set(key, valve);
}
return valve;
}
await valveFor(`shop:${shopId}`).run(() => syncShop(shopId));Events
| Bottleneck | valve |
|---|---|
error | store-error for store failures, warning for listener errors |
failed | Catch the rejection from run |
retry | stats().totals.retried |
idle | idle |
empty | No equivalent |
dropped | reject with code QUEUE_FULL |
depleted | No equivalent. pause fires when the provider reports no remaining quota. |
debug, lifecycle events | No equivalent |
What you gain
- Provider feedback.
Retry-After, IETFRateLimit, andx-ratelimit-*headers pause every worker. See Respect provider feedback. - Multiple rules per limiter, checked atomically.
- Cancellation and queue deadlines with
AbortSignalandmaxWait. - Typed errors with stable codes.
- No dependencies, and no Redis client bundled or created for you.