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.
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% |
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.
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.
- Fix everything except concurrency. Same subscription list, same payload bytes, same TTL, same node size, cold process each run.
- Sweep in powers of two from 1 to 1,024, recording throughput, p50, p99, 429 count and socket-error count for each run.
- 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.
- 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.
- Re-run at your real payload size. A 3.5 KB
aes128gcmpayload 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.allwith amapis 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
maxSocketsexplicitly and raiseulimit -non the sender node before concluding that the push service throttled you —EMFILEand429demand 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.
Related
- Optimal Batch Size for Web Push Throughput — how chunk size interacts with the concurrency knee and why worker pools beat fixed batches.
- Scaling Push Queues with Redis or RabbitMQ — feeding a bounded pool without letting queue depth hide a saturated sender.
- Handling 429 Too Many Requests from Push Services — the backoff behaviour that keeps a past-the-knee run from turning into a retry storm.
- HTTP/2 Connection Pooling for Web Push at Scale — session reuse, stream limits and how the transport layer sets the ceiling these benchmarks measure.