Adaptive send-pacing
Adaptive pacing is a closed-loop rate controller for campaign sends. While a campaign runs, it reads the campaign’s own delivery receipts (DLRs) grouped per carrier (MCCMNC), computes a pace factor between a floor and1, and applies it to the effective send rate — the messages-per-second rate and the inter-batch delay both scale down as carrier-side failures rise. Healthy traffic keeps the factor at 1 and nothing changes. A carrier that starts failing or spam-blocking drags the pace down proportionally, so the destination network sees a lighter, recoverable load instead of a flat wall of filtered traffic.
It is off by default and opt-in per campaign. Campaigns without the setting are untouched.
Why the static rate alone is not enough
A campaign’s send pace is otherwise open-loop:throttle_rateis a fixed messages-per-second cap. It sets a ceiling, but it never reconsiders that ceiling mid-blast.ramp_upwarms a sender on a fixed linear curve. The curve climbs on schedule whether the carrier is accepting the traffic or rejecting half of it.- The distributed rate ceiling enforces a cross-worker cap, but nothing in the pipeline lowers it when delivery goes bad.
Opt in per campaign
Setadaptive_pacing on the campaign’s variables object — the same self-serve opt-in pattern as ramp_up, send_window, trust_tier_governor, and channel_optimization. No schema or settings change is needed; a campaign row carrying the key opts in, and a campaign created without it behaves exactly as before.
Values outside their valid band reset to the defaults, and a
max_failure_rate at or below healthy_failure_rate collapses both back to defaults — the controller keeps running even if one override was junk. Both the snake_case names above and their camelCase equivalents (adaptivePacing, healthyFailureRate, …) are accepted on the config key.
To turn it off, remove the key (or set enabled: false). Legacy campaigns have no key and parse as disabled, so there is zero behaviour change for anything you have already sent.
How the pace factor works
While the campaign runs, the executor periodically groups the campaign’s own recent send records by carrier and counts three things over the rolling window:- terminal — messages that reached a final state in-window (the denominator),
- failed — failure-mode terminals (failed, undelivered, rejected),
- blocked — the spam-block subset of
failed: rejected status or a spam/block/filter/4xx-class response from the destination network.
(failed + blocked) / terminal. Spam blocks are counted double — once inside failed and once again as blocked — because a carrier actively filtering your traffic costs sender reputation far more than transient undeliverable churn.
The stress rate maps linearly onto the pace factor:
- stress at or below
healthy_failure_rate→ factor1(full rate), - stress at or above
max_failure_rate→ factormin_pace_factor(the floor), - between them → proportional interpolation between the two.
- effective messages per second multiply by the factor — factor
0.6on a 50 MPS campaign sends 30 MPS, - inter-batch delay divides by the factor — factor
0.6widens a 1 s gap between batches to ~1.7 s.
smoothing), so a single noisy window doesn’t slam the rate — the factor walks toward the reading over a couple of refreshes instead of oscillating.
Three properties keep it safe to leave on:
- Degrade-open. If the receipt read returns nothing (no rows yet, a transient error), the factor is
1— the controller is a reputation optimiser, not a hard stop. Your credit caps and circuit-breaks still own actual halts. - Small samples don’t throttle. A carrier under
min_sampleterminals contributes factor1rather than a scary-looking 50% off two messages. - The floor is non-zero. Even the worst carrier drains at
min_pace_factorof the configured rate instead of stalling the campaign.
Carrier-aware, not one global knob
Every carrier (MCCMNC) is scored independently on its own receipts, then blended into one campaign factor weighted by recent terminal traffic on that carrier:- A destination network carrying most of your volume dominates the blend — when it starts blocking, the whole campaign slows hard.
- A tiny stressed carrier barely moves the blend — one bad two-percent slice doesn’t tank an otherwise healthy blast.
When not to use it
Adaptive pacing reads this campaign’s receipts. Skip it when that signal can’t exist or shouldn’t drive the rate:- Journey and drip campaigns. A triggered drip enrolls contacts one at a time; there is no batch volume to pace and no meaningful per-window receipt signal. Keep pacing on blast-type sends.
- Low-volume or internal routing sends. A campaign sending a few messages per window never reaches
min_sampleon any carrier — the controller contributes nothing, and the per-refresh grouping is wasted work. - Campaigns that need to finish inside a hard window. Pacing widens the inter-batch delay when carriers stress; a blast with a fixed completion deadline should run on its static
throttle_rateand accept operator monitoring instead. - When you want a hard stop, not a slowdown. Pace control slows a stressed carrier; it never stops the campaign by design. Use your circuit-break, credit-cap, or a manual pause for “halt this blast” semantics.
Reading the outcome
When the factor drops below1, the campaign emits an executor log line naming the applied factor, the most-stressed carrier’s MCCMNC, its stress rate, and the number of throttled carriers — the per-carrier delivery breakdown behind those numbers is the same one the campaign dashboards surface through the analytics pipeline, so you can confirm which destination network is slowing the blast before you override anything.
Related
- Voice & messaging pricing and throughput — the static
throttle_rateandramp_upknobs this complements. - Campaign limits and quiet hours — admission and timing gates; pacing is the third leg, pace.
- Campaign lifecycle — where campaign state transitions happen; pacing is orthogonal and survives pause/resume.