Parallel vs Sequential Push Sending Benchmarks

Sending 50,000 web push messages one at a time takes half an hour. Sending all 50,000 at once takes longer and loses a fifth of them. The useful throughput lives in a narrow band between those two failures, and the only way to find it for your own infrastructure is to measure it.

Quick Answer

Strategy Throughput Failure rate Verdict
Sequential (await in a loop) ~27 msg/s 0% Correct, unusably slow — one round trip at a time
Bounded concurrency (64–256 in flight) 1,180–1,455 msg/s 0–0.4% The operating range; 40–50× sequential
Unbounded fan-out (Promise.all over the list) ~190 msg/s effective 22% Slower than bounded and lossy — never ship this

The throughput curve rises steeply to roughly 128 in-flight requests per sender node, flattens between 128 and 256, and then inverts. Past the knee you are paying latency and error budget for no additional goodput.

The Three Models

Sequential issues one POST to a subscription endpoint, awaits the response, then issues the next. Wall-clock time is n × RTT. With a 36 ms median round trip to a Google push endpoint, that is 27 messages per second regardless of how much CPU the sender node has. The encryption work — one aes128gcm ephemeral key agreement per message under RFC 8291 — is dwarfed by network wait.

Bounded concurrency keeps a fixed number of requests in flight with a semaphore or a worker pool. The pool size is the only tuning knob that matters, and it must be per sender node, not per campaign. Because HTTP/2 multiplexes many streams over one TCP connection, this model reuses connections rather than opening thousands of them — the same property exploited in HTTP/2 connection pooling for web push at scale.

Unbounded fan-out maps the whole subscription list into promises and hands them to Promise.all(). Every request is created immediately. The event loop, the socket pool, the DNS resolver and the push service’s per-origin rate limiter all receive the entire load at once, and each of them degrades in a way that makes the others worse.

Sequential, bounded and unbounded send models over wall-clock time Three horizontal lanes. The sequential lane shows twelve request blocks laid end to end across the whole time axis and a wall clock of 30 minutes 51 seconds. The bounded pool lane shows the same twelve blocks stacked three deep in a quarter of the span with a wall clock of 35 seconds and no throttling. The unbounded lane shows all requests issued at once followed by a long red retry storm bar, a wall clock of 4 minutes 23 seconds and 22 percent of messages never sent. Sequential 1 in flight 50,000 sends in 30 m 51 s — 27 msg/s, zero errors Bounded pool 128 in flight Same work in 35 s — 1,410 msg/s, no throttling Unbounded 50,000 in flight 429 responses, stream resets, retry storm 4 m 23 s and 22% of messages never sent at all elapsed wall-clock time
The same 50,000 sends under three concurrency models. Unbounded fan-out finishes last because most of its elapsed time is spent retrying work it already threw away.

The Benchmark Harness

The harness below is deliberately minimal: a fixed subscription list, one strategy per run, and a single metrics object. Run it against a staging VAPID identity and a set of endpoints you own — benchmarking against real subscribers sends real notifications.

// bench.js — node >=18. Usage: node bench.js <concurrency|0-for-unbounded>
import webpush from 'web-push';
import { performance } from 'node:perf_hooks';

webpush.setVapidDetails(
  'mailto:ops@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY,
);

const subscriptions = JSON.parse(process.env.BENCH_SUBSCRIPTIONS);
const payload = JSON.stringify({ title: 'bench', body: 'x'.repeat(512) });
const limit = Number(process.argv[2] ?? 128);

const m = { ok: 0, throttled: 0, gone: 0, socket: 0, other: 0, latencies: [] };

async function sendOne(sub) {
  const t0 = performance.now();
  try {
    await webpush.sendNotification(sub, payload, { TTL: 300, urgency: 'normal' });
    m.ok += 1;
  } catch (err) {
    if (err.statusCode === 429) m.throttled += 1;
    else if (err.statusCode === 410) m.gone += 1;
    else if (['ECONNRESET', 'EMFILE', 'ENOTFOUND', 'ETIMEDOUT'].includes(err.code)) m.socket += 1;
    else m.other += 1;
  } finally {
    m.latencies.push(performance.now() - t0);
  }
}

// Bounded pool: `limit` workers pulling from one shared cursor.
async function bounded(n) {
  let cursor = 0;
  const worker = async () => {
    while (cursor < subscriptions.length) {
      const i = cursor++;
      await sendOne(subscriptions[i]);
    }
  };
  await Promise.all(Array.from({ length: n }, worker));
}

const started = performance.now();
if (limit === 1) {
  for (const sub of subscriptions) await sendOne(sub);   // sequential
} else if (limit === 0) {
  await Promise.all(subscriptions.map(sendOne));         // unbounded fan-out
} else {
  await bounded(limit);                                  // bounded concurrency
}
const seconds = (performance.now() - started) / 1000;

const sorted = m.latencies.sort((a, b) => a - b);
const pct = (p) => Math.round(sorted[Math.floor(sorted.length * p)] ?? 0);
console.log(JSON.stringify({
  concurrency: limit,
  seconds: Number(seconds.toFixed(1)),
  throughput: Math.round(m.ok / seconds),
  p50_ms: pct(0.5), p99_ms: pct(0.99),
  ...m, latencies: undefined,
}));

Three rules make the numbers comparable between runs. Pin the payload size — encryption cost scales with plaintext length and the 4 KB ceiling for aes128gcm payloads means a 3.9 KB body behaves nothing like a 200-byte one. Reset the process between runs so HTTP/2 session state and DNS caches start cold. Never read process.env.VAPID_PRIVATE_KEY from a checked-in file; the harness signs a real JWT and a leaked key is a leaked sender identity.

Results

Measured on one 4 vCPU node, Node 20, 50,000 subscriptions against fcm.googleapis.com, 512-byte payloads, TTL: 300. Median network round trip 36 ms.

Concurrency Throughput (msg/s) p50 p99 429s Socket errors Wall clock Delivered
1 (sequential) 27 36 ms 88 ms 0 0 30 m 51 s 100%
8 205 38 ms 96 ms 0 0 4 m 04 s 100%
32 742 42 ms 121 ms 0 0 1 m 07 s 100%
64 1,180 53 ms 168 ms 0 0 42 s 100%
128 1,410 89 ms 402 ms 3 0 35 s 100%
256 1,455 173 ms 1,090 ms 214 0 34 s 99.6%
512 1,120 441 ms 3,900 ms 2,918 61 45 s 94.1%
Unbounded (50,000) 190 11,400 8,700 4 m 23 s 78.0%
Throughput against worker concurrency for 50,000 web push sends A bar chart with concurrency on the horizontal axis and messages per second on the vertical axis. Throughput rises from 27 at concurrency 1 to 205 at 8, 742 at 32, 1180 at 64 and 1410 at 128, peaks at 1455 at 256, then falls to 1120 at 512 and 190 with unbounded fan-out. The 128 bar is marked as the knee; the 512 and unbounded bars are marked with 429 counts and a 22 percent failure rate. Throughput vs concurrency — 50,000 sends, one node, HTTP/2 msg/s 1,500 1,000 500 0 27 205 742 1,180 1,410 1,455 1,120 190 1 8 32 64 128 256 512 all knee 214 x 429 2,918 x 429 22% lost in-flight requests per sender node
Throughput gains stop at 128 in flight. Everything between 128 and 256 buys latency, and everything past 256 buys errors.

The shape is the point. Between 1 and 64 the curve is close to linear — each additional worker fills otherwise-idle network wait. Between 64 and 128 it bends: p99 latency more than doubles for a 19% throughput gain. Between 128 and 256 throughput moves 3% while p99 goes from 402 ms to 1,090 ms and the first meaningful 429s appear. Past 256 goodput falls, because every retried request competes with fresh work for the same connection budget.

Why Unbounded Fan-Out Collapses

Three independent limits fail almost simultaneously.

HTTP/2 stream limits. A push service advertises SETTINGS_MAX_CONCURRENT_STREAMS, commonly 100 to 1,000 per connection. Requests beyond that are queued client-side or rejected with REFUSED_STREAM. Node’s HTTP/2 client does not open a second session automatically, so 50,000 promises pile up behind a few hundred usable streams — the “parallelism” is imaginary.

Ephemeral ports and file descriptors. When the client falls back to HTTP/1.1, or when the agent opens additional sessions, socket count explodes. EMFILE from an exhausted descriptor table and ECONNRESET from a saturated conntrack table both appear as generic network errors, which is why the socket-error column in the table matters more than the 429 column for diagnosis.

Rate-limit feedback. Push services throttle per origin. A burst that trips the limiter returns 429 with Retry-After, and a naive sender immediately re-enqueues every failure — arriving as a second burst while the limiter is still hot. Honouring Retry-After with jitter, as described in handling 429 Too Many Requests from push services, is what stops the loop.

The unbounded fan-out collapse cycle A four-stage clockwise cycle. Unbounded fan-out with fifty thousand concurrent requests exhausts ephemeral ports and the HTTP/2 stream limit. The push service answers with 429 responses and stream resets. Failures are re-enqueued immediately, amplifying the next wave and returning to the start. A central panel shows goodput falling from 1,455 to 190 messages per second. Unbounded fan-out 50,000 requests created at once Client-side saturation stream limit, ports, descriptors 429 and stream resets origin rate limiter engages Immediate re-enqueue failures become the next burst Goodput 1,455 to 190 messages per second Each turn of the loop adds load exactly when the push service is trying to shed it.
Unbounded fan-out is self-reinforcing: the failures it produces become the load that produces more failures.

Finding Your Own Knee

Your knee will not be 128. It depends on round-trip time to the push service, payload size, vCPU count and whether encryption runs on the event loop thread. Measure it in five steps.

  1. Fix everything except concurrency. Same subscription list, same payload bytes, same TTL, same node size, cold process each run.
  2. Sweep in powers of two from 1 to 1,024, recording throughput, p50, p99, 429 count and socket-error count for each run.
  3. Plot throughput and p99 on the same axis. The knee is where the throughput curve’s slope drops below roughly 10% per doubling while p99 is still rising steeply.
  4. Back off one step from the first run that produces any 429. In the table above the first 429s appear at 128, so the production setting is 128 with a limiter, not 256.
  5. Re-run at your real payload size. A 3.5 KB aes128gcm payload moves encryption cost from negligible to material and typically pulls the knee down by 25–40%.

Then divide by fleet size. A knee of 128 per node across 6 sender nodes means a global limiter of 768 in flight — and the push service sees the aggregate, not the per-node figure. Track that global number in the same place you track queue depth and consumer lag, because a node that scales out silently doubles your effective concurrency.

Gotchas and Edge Cases

  • Promise.all with a map is unbounded even when it looks batched. Chunking the list into groups of 500 and awaiting each chunk still puts 500 requests in flight, and the whole chunk waits for its slowest member. A worker pool pulling from a shared cursor keeps all workers busy and is strictly better; the trade-offs are quantified in optimal batch size for web push throughput.
  • Throughput measured over successful sends only is a vanity metric. The unbounded run in the table technically issued 50,000 requests in 4 m 23 s; it delivered 39,000. Always report goodput, and always report it against the accepted count, not the attempted count.
  • Node’s default HTTP agent caps sockets per host at infinity but the OS does not. Set maxSockets explicitly and raise ulimit -n on the sender node before concluding that the push service throttled you — EMFILE and 429 demand opposite responses.
  • Different push services have different knees. Mozilla Autopush, Apple’s Web Push endpoints and FCM all publish different limits and enforce them differently. Benchmark per host and keep a per-host limiter rather than one global number.
  • Encryption on the event loop caps you before the network does. webpush.sendNotification() performs the ECDH and AES-GCM work synchronously. Above roughly 2,000 msg/s on a 4 vCPU node, CPU becomes the constraint and adding concurrency only grows the latency queue.

FAQ

Is sequential sending ever the right choice?

Only for very small audiences or for strictly ordered notifications where message N must be displayed before message N+1 — and even then, ordering is not guaranteed once the push service is involved, so the guarantee is illusory. For anything above a few hundred subscribers, a bounded pool with a concurrency of 8 is already 7× faster with identical reliability, and it is no harder to write.

Why did unbounded fan-out finish slower than sequential in some runs?

Because the retry work dominates. Once 22% of requests fail and are re-enqueued, the sender is doing 1.22× the original work, on a connection pool that is degraded, against a rate limiter that is actively penalising the origin. Sequential sending never trips the limiter, so its 27 msg/s is sustained for the whole run while the unbounded run’s effective rate collapses after the first few seconds.

Does HTTP/2 connection reuse change where the knee sits?

Substantially. With a single reused HTTP/2 session, concurrency is bounded by the server’s advertised maximum concurrent streams, and the knee tends to land just below that value. Opening several sessions per host raises the ceiling but also multiplies your visible request rate at the push service’s rate limiter, which usually moves the first-429 point down. Measure with the exact connection strategy you will run in production.

Back to Message Batching & Throughput Optimization