Skip to main content

Rate limiting & backpressure

Every throughput throttle lives under one limiter group on the queue declaration, beside the handler it tunes. All of these are process-local — they apply in-process, to that one running instance only. There is no cluster-wide coordination: two instances each keep their own counters. Omit the whole group to run with defaults (concurrency 1, unbounded rate).

The limiter options​

Everything you can set on createQueue({ limiter, handler }), all scoped to this queue in this process:

OptionTypeDefaultWhat it does
concurrencynumber1Max handlers running in parallel for this queue here.
rateMaxnumberunset (unbounded)Token-bucket cap: max jobs started per window. Requires rateDurationSeconds.
rateDurationSecondsnumberunsetThe window for rateMax, in seconds — e.g. 60. Requires rateMax.
batchSizenumberconcurrencyMax jobs claimed per fetch.
pollIntervalSecondsnumber5Poll cadence when LISTEN/NOTIFY is unavailable, in seconds.
lockDurationSecondsnumber30Visibility timeout / heartbeat window for a claimed job, in seconds.

None of these are persisted or shared: they tune how hard this process pulls from the queue. To change fleet-wide throughput, add or remove instances.

Whether processing pauses under system pressure is not a limiter option — it follows from systemLimiter on the PortQueue (below).

Process-local rate cap​

  • Queue rate cap — rateMax + rateDurationSeconds form a process-local, in-memory token bucket (exact for this queue here): at most rateMax jobs started per rateDurationSeconds window. Both are required together. Use it to bound one process's throughput, e.g. to protect a third-party API.

PortQueue has no cluster-wide job-rate cap — there is no shared DB counter throttling how many jobs the fleet may process. If a process is consuming a queue, it claims the work that's there; scale throughput by adding/removing instances, and bound a single process with its local rate cap. (Cluster-wide pause is still available via queue and instance pause.)

const pq = new PortQueue({
driver,
// Defining this is what makes every queue this process consumes respect pressure. Omit it and
// none do.
systemLimiter: { maxEventLoopDelaySeconds: 0.2, maxCpuPercent: 85 },
});

pq.createQueue({
name: 'third-party',
limiter: {
concurrency: 10,
rateMax: 50, // this process's local cap…
rateDurationSeconds: 60, // …per 60-second window
},
handler: async (job) => callThirdParty(job.data),
});

rateMax and rateDurationSeconds must be set together; the window is a plain number of seconds.

System-pressure backpressure​

systemLimiter is both the configuration and the switch. Pass it and every queue this client consumes stops claiming while any threshold you set is breached, resuming on the first sample where every watched metric is back at or under its limit. Omit it — the default — and nothing throttles.

There is deliberately no per-queue opt-in. A limiter describes the health of the process, and one queue continuing to claim while the others back off would defeat the point; the earlier limiter.respectSystemPressure flag also made it easy to configure thresholds that silently did nothing.

CPU and memory are this process's, not the machine's

maxCpuPercent is this Node process's CPU over the sample window as a share of all the cores available to it (os.availableParallelism()), 0–100 — 85 means it is using 85% of the CPU the host will give it, whether that host has 2 cores or 64. maxMemoryPercent is the process's used heap as a share of the limit V8 would abort at (--max-old-space-size), not of machine RAM.

Host-wide readings were tried and are the wrong signal. Linux and macOS both count memory that is merely allocated as used, so os.freemem() reports 90%+ on a machine doing nothing much and a memory threshold never clears. And a process in a container has a limit far below the host's — the host number is not the ceiling it dies at. Two PortQueue processes on one box now report their own figures, not a shared one.

Monitoring always runs; only limiting is opt-in

Process CPU and heap are measured whether or not you set a systemLimiter — the instance heartbeat records them, which is what the dashboard's per-instance health and the portqueue instances command read. That can't be switched off. Event-loop delay is the exception: measuring it keeps a perf_hooks histogram enabled for the life of the process, so it is only sampled when maxEventLoopDelaySeconds gives it a purpose.

Pressure gating and the rate limits above throttle claiming independently — they compose.

Every systemLimiter option​

OptionTypeDefaultWhat it does
sampleIntervalSecondsnumber2How often metrics are sampled, in seconds. Every threshold is checked once per sample, so this is also the worst-case reaction time.
maxEventLoopDelaySecondsnumberunsetPressure when the mean event-loop delay since the last sample exceeds this many seconds (0.2 = 200 ms). The best single signal that the process is saturated, and the only metric not measured at all until you set it.
maxCpuPercentnumberunsetPressure when this process's CPU since the last sample exceeds this share (0–100) of the cores available to it. 85 is a reasonable cap on any core count.
maxMemoryPercentnumberunsetPressure when this process's used heap exceeds this share of V8's heap limit (0–100) — the limit --max-old-space-size sets, not machine RAM.

Every threshold is optional and only the ones you set are checked; there are no defaults to merge with, so systemLimiter: {} samples but can never report pressure.

maxCpuPercent is core-count independent

It is a share of everything the host will give this process (os.availableParallelism()), so 85 means the same on a 2-core container and a 64-core server, and never reads past 100.

When you want "the process is saturated" rather than "it is busy", use maxEventLoopDelaySeconds: a process can sit at 30% CPU and still be unable to keep up.

Thresholds are evaluated in a fixed order — maxEventLoopDelaySeconds, maxCpuPercent, maxMemoryPercent — and the first breach wins: that's the metric reported on the event. Recovery needs every watched metric back at or under its threshold, not just the one that breached, and takes effect on the first sample where that holds.

const pq = new PortQueue({
driver,
systemLimiter: {
sampleIntervalSeconds: 2, // sampling cadence, in seconds
maxEventLoopDelaySeconds: 0.2, // 200 ms of mean event-loop delay
maxCpuPercent: 85, // this process at 85% of the cores it can use
maxMemoryPercent: 85, // this process's heap, as a share of V8's limit
},
});

pq.on(PortQueueEvent.SYSTEM_PRESSURE_HIGH, ({ metric, value, threshold }) => {
// metric: 'eventLoopDelaySeconds' | 'cpuPercent' | 'memoryPercent'
});
pq.on(PortQueueEvent.SYSTEM_PRESSURE_NORMAL, () => {});

A pressure-high transition is also logged at warn (with the metric, value and threshold), and the return to normal at info.