valve_

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 (limit per interval) instead of minTime and 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 Group class, and no runtime updateSettings.

There is no compatibility layer. Translate pacing and quota policies explicitly; reservoir reset times and chained reservations can change behavior.

Quick comparison

Before: Bottleneck
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');
After: valve
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

Bottleneckvalve
idid (required, no { or })
maxConcurrent (local)concurrency: n
maxConcurrent (clustered)concurrency: { limit: n, scope: 'global' }
minTime: msrate: { limit: 1, interval: ms }
reservoir + reservoirRefreshAmount + reservoirRefreshIntervalrate: { limit: amount, interval, algorithm: 'fixed-window' }
reservoir + reservoirIncreaseAmount + reservoirIncreaseInterval + reservoirIncreaseMaximumrate: { limit: amount, interval, burst: maximum }
highWater with strategy: OVERFLOWmaxQueue
highWater with LEAK, OVERFLOW_PRIORITY, or BLOCKNo equivalent. maxQueue rejects the new job.
datastore, clientOptions, clusterNodesstore: redisStore({ client }) with a client you create
clearDatastoreNo direct equivalent. Follow the coordinated quota migration procedure.
timeout (Redis key TTL)Not needed. Keys expire on their own.
PromiseNot 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

Bottleneckvalve
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 idleidle(), or on('idle')
updateSettings(options)No runtime policy mutation. Coordinate a quota migration.
incrementReservoir, currentReservoirNo 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.

Bottleneck: independent chained reservations
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' }),
  }),
);
Valve: one atomic admission
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-After pauses 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

Bottleneckvalve
priority (0-9)priority (any integer, higher first)
weightcost
expirationNo direct equivalent. See Expiration.
idNot 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 leaseTtl if 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 valid Retry-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 run with 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 behaviorvalve 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

Bottleneckvalve
errorstore-error for store failures, warning for listener errors
failedCatch the rejection from run
retrystats().totals.retried
idleidle
emptyNo equivalent
droppedreject with code QUEUE_FULL
depletedNo equivalent. pause fires when the provider reports no remaining quota.
debug, lifecycle eventsNo equivalent

What you gain

  • Provider feedback. Retry-After, IETF RateLimit, and x-ratelimit-* headers pause every worker. See Respect provider feedback.
  • Multiple rules per limiter, checked atomically.
  • Cancellation and queue deadlines with AbortSignal and maxWait.
  • Typed errors with stable codes.
  • No dependencies, and no Redis client bundled or created for you.

On this page