campaign-sender · visual documentation · 2 of 2

Benchmarks & the real bottleneck

Every number below is from a real run of the delivered code — dry-run transport simulating an 80ms bulk API with ±30% jitter, ~1% transient failures.

Emails processed
1,000,000
998,981 sent · 1,019 rejects logged
Wall clock
7.4s
vs ~42h sequential baseline
Steady-state throughput
~140K/s
91% of theoretical ceiling
Duplicates after crash + resume
0
1,000 unique checkpoint entries ✓

Cumulative throughput across the 1M-recipient run

The ramp is the pipeline filling its 20 in-flight slots; the plateau is steady state, where a new batch launches the instant any slot frees. Small dips line up with retry backoffs (14 across the run).

Cumulative average emails/s vs. % of campaign complete. Hover for exact values.
View as table
% completecumulative emails/s

Why batching wins: the request count collapses

A bulk ESP request carries one shared template plus 1,000 recipient personalizations — the provider fans out delivery on its side.

Individual sends
1,000,000
requests
→ ÷1,000 →
Batched sends
1,000
requests (+14 retries, measured)
Fewer requests means less duplicated auth, headers and connection overhead — and the bytes collapse too: templates + bare variables are ~115 MB on the wire vs ~1–2 GB of client-rendered bodies, a ~10–15× compression no other design recovers.

Rate is a separate control from concurrency

The semaphore bounds work in flight (our memory and sockets); the token bucket bounds request starts/sec (the provider's quota). With only the semaphore, effective rate is concurrency ÷ provider latency — a number the provider controls — and 429-retry becomes the flow control. Measured: 200k recipients against a simulated 50 req/s quota.

ConfigurationWall clock429s (wasted round trips)Total requests
Semaphore only — no --rate4.2 s75276
Token bucket — --rate 50two gates4.2 s3203
Wall clock ties because both runs are quota-bound — what the limiter buys is 27% fewer requests for the same work, a retry budget that stays reserved for genuine faults, and a client the provider doesn't want to throttle. The residual 3 are boundary races from running at exactly 100% of quota; ~95% removes them. Full prose: docs/02-architecture.md, "The two gates".

Five approaches, one honest bottleneck

Durations span five orders of magnitude, so a table is the honest form — bars on a log axis would visually flatten a 20,000× difference.

ApproachRequests for 1MWall clockWhat actually limits it
1 · Sequential loop1,000,000~42 hRound-trip latency, paid serially
2 · Bounded concurrency, individual1,000,000~25 minProvider rate limits — 429s long before your network saturates
3 · Bulk API + batchingchosen~1,0007.4 s measured*Your provisioned ESP quota — the only unavoidable limit
4 · Raw SMTP, pooled1,000,000 msgsIP reputation: greylisting, tarpits, blackholes
5 · Queue + workers~1,000seconds–minSame as 3, plus a Redis to operate
*Against a simulated 80ms bulk API. Against a real ESP the same code is limited by the account's provisioned sending rate — e.g. at 10k emails/s provisioned, 1M takes 100s and no client-side change moves that number. Full analysis: docs/01-problem-and-approaches.md and docs/04-performance.md.

Concurrency sweep: the client ceiling vs. the quota wall

200k recipients per config, one run each. Unlimited: throughput scales near-ideally to c=20, then the dispatch side (CSV parsing + per-batch CPU) caps the client at ~330k emails/s. Under a 50 req/s quota with no client limiter: throughput pins at the quota from c=10 while latency degrades — and past saturation it's the median that collapses, not just the tail.

Throughput (emails/s)

Batch completion latency, p99 (ms)

no quota 50 req/s quota, no --rate
Extra concurrency past quota saturation buys zero throughput and ~7× median latency — self-inflicted 429s (0 → 222) turn waiting into retry work. Full table and analysis: docs/05-sweep.md.
View as table
RegimecWallEmails/sp50p95p99429s