CRM Strategy

Frequency Caps and Campaign Collisions: Build a Reproducible Allocation Replay

Two eligible campaign audiences do not equal two independent sends. Reconcile overlap, previous message history and priority with a tested person-level replay before interpreting lifecycle performance.

Your onboarding campaign has 1,000 eligible people. Your promotional campaign has 800. Adding the two numbers produces 1,800 candidate messages, but not necessarily 1,800 sends or 1,800 people. Some people belong to both audiences, some have no remaining message capacity, and whichever campaign wins a contested slot changes the exposure received by that person. Before debating subject lines or attributing revenue, make that allocation explainable.

Editorial disclosure: Original AI-assisted Growthcraft Editorial synthesis. Examples are synthetic, not Akshay's campaign results. The JavaScript reference is tested in Node.js 24. It is an offline planning replay, not production sending code, a legal assessment or an implementation of any provider's internal scheduler.

Four things to take into your next lifecycle review

  • Deduplicate at the person level and distinguish audience overlap from candidate message volume.
  • Separate messages blocked by previous history from messages displaced by the current campaign collision.
  • Compare both priority orders: the winner can change while total unique reach remains identical.
  • Verify eligibility, history and provider settings before turning a planning result into an operational change.

Why the cap contract deserves a fresh review

Customer.io's documentation, updated 14 September 2026, explains that its limits use relative time windows and that retry configuration changes how blocked attempts are handled. A calendar-day spreadsheet is therefore not automatically a valid representation of a configured sending workflow. This current documentation is a practical review signal, not keyword-volume evidence or a claim that the feature itself launched this month.

Braze separately documents throughput rate limits and user-frequency controls. Slowing a queue and limiting a person's contact pressure are different tasks. This guide uses an original provider-neutral snapshot model; it does not infer workspace settings from documentation. The broader lifecycle control-tower guide covers orchestration strategy. Here we focus on the narrower engineering question: can you reproduce who gets the remaining slot?

Define the snapshot before counting anything

Write a contract containing the channel, anonymous campaign labels, UTC cutoff, identity key, cap rule and counted history event. Define the available population after consent, preferences and channel eligibility, but before applying the frequency cap. Each person can have one candidate for A and one for B. Repeated triggers for the same campaign must be reconciled upstream; counting each trigger as another person breaks the model.

For this replay, every person has either zero or one remaining slot. That may be the remainder under a larger policy, but it is not valid when some people have two slots available. Both campaigns compete at the same frozen snapshot, and no other campaign, retry or history expiry changes capacity during allocation. These restrictions make the arithmetic reproducible. They are not claims about how a real asynchronous messaging system behaves.

Keep eligibility and capacity separate. A person with a slot but without appropriate channel permission is not an eligible recipient. A cap does not establish consent, and a priority flag cannot override preferences. Necessary transactional communications belong in a correctly reviewed operational process. Do not relabel promotional content as transactional to make it escape the limits that apply to marketing.

Build three disjoint groups, not two competing totals

Let A and B represent unique eligible people, with intersection O. The disjoint groups are A-only = A − O, B-only = B − O and both = O. Their sum, A + B − O, is the audience union. The number of candidate requests is A + B because a person in the overlap has two candidates. Both quantities are useful; using the same label for them is the problem.

Within those groups, count already-capped people as C_A, C_B and C_O. These are people with zero remaining slots, not previous-message counts. Someone who received several messages still contributes only one to their capped group. Validate O ≤ min(A,B), C_A ≤ A − O, C_B ≤ B − O and C_O ≤ O. Negative, fractional and missing counts must fail explicitly rather than being rounded or converted to zero.

Use one history source and one identity rule for all three groups. Joining A to a current user table while joining B to an older email-address table can create a perfectly neat but meaningless intersection. Report unmatched identities and incomplete history as unknown. Do not assume that missing history means no previous messages: that choice mechanically creates extra capacity precisely where evidence is weakest.

Reproduce the allocation and its conservation checks

Available exclusive groups are X = A − O − C_A and Y = B − O − C_B. Available overlap is Z = O − C_O. Giving A first priority selects X + Z requests for A and Y for B. Giving B first priority selects X for A and Y + Z for B. Both select X + Y + Z people. Reversing priority reallocates exactly Z slots; it cannot create additional unique reach under these assumptions.

History blocks C_A + C_B + 2C_O requests because a capped overlap person has two candidates. The new collision blocks Z more requests. These reasons are mutually exclusive in the replay: history is checked first, and only people with capacity can experience the new collision. Requested = selected + history-blocked + collision-blocked is a useful conservation invariant. It catches double-counting even when a dashboard total looks plausible.

In the synthetic example, A is 1,000, B is 800 and O is 400. Already-capped counts are 100 A-only, 50 B-only and 80 in both. Therefore X = 500, Y = 350 and Z = 320. The union contains 1,400 people, of whom 230 are capped. A-first selects 820 for A and 350 for B; B-first selects 500 for A and 670 for B. From 1,800 candidate requests, 310 are blocked by history, 320 by collision and 1,170 are selected.

Nothing here says the selected requests were delivered. Nothing establishes that A creates more customer value than B. The calculation explains allocation under a chosen rule. Treating the 320 displaced messages as lost revenue would require assumptions about outcomes and a counterfactual that these counts do not provide.

A tested person-level reference replay

The following pure JavaScript function works on a normalised snapshot with one anonymous row per person. Each row has a nonempty ID, a zero-or-one slot value and a unique candidate list containing A, B or both. IDs are local synthetic fixtures here; do not paste real customer records into public tools. The function refuses duplicate rows, unknown campaigns, repeated candidates and missing slot values. It returns a trace rather than only a summary so discrepancies can be investigated.

function replayCollision(rows, priority = 'A') {
  if (!Array.isArray(rows) || !['A', 'B'].includes(priority)) {
    throw new Error('Expected rows and priority A or B');
  }
  const ids = new Set();
  const totals = { requested: 0, selectedA: 0, selectedB: 0,
    historyBlocked: 0, collisionBlocked: 0 };
  const trace = [];
  for (const row of rows) {
    if (!row || typeof row.id !== 'string' || !row.id.trim() ||
        ids.has(row.id) || ![0, 1].includes(row.slots) ||
        !Array.isArray(row.candidates) || row.candidates.length === 0 ||
        row.candidates.some(c => !['A', 'B'].includes(c)) ||
        new Set(row.candidates).size !== row.candidates.length) {
      throw new Error('Invalid or duplicate snapshot row');
    }
    ids.add(row.id);
    const ordered = [...row.candidates].sort((a, b) =>
      a === priority ? -1 : b === priority ? 1 : 0);
    let available = row.slots;
    for (const campaign of ordered) {
      totals.requested++;
      const reason = row.slots === 0 ? 'history_blocked' :
        available === 0 ? 'collision_blocked' : 'selected';
      if (reason === 'selected') {
        available--;
        totals[campaign === 'A' ? 'selectedA' : 'selectedB']++;
      } else if (reason === 'history_blocked') totals.historyBlocked++;
      else totals.collisionBlocked++;
      trace.push({ id: row.id, campaign, reason });
    }
  }
  const selected = totals.selectedA + totals.selectedB;
  if (totals.requested !== selected + totals.historyBlocked +
      totals.collisionBlocked) throw new Error('Conservation failed');
  return { totals, trace };
}
console.log(replayCollision([
  { id: 'example-1', slots: 1, candidates: ['B', 'A'] },
  { id: 'example-2', slots: 0, candidates: ['A', 'B'] },
  { id: 'example-3', slots: 1, candidates: ['B'] }
], 'A').totals);
// { requested: 5, selectedA: 1, selectedB: 1,
//   historyBlocked: 2, collisionBlocked: 1 }

This is a deterministic selection reference, not a production dispatcher. It has no credentials, network requests, scheduling, consent database or durable reservations. It cannot prevent two real workers from consuming the same slot. It does not mutate the supplied rows, and the chosen priority is independent of the incoming candidate order. Those properties make it useful for fixtures and shadow review without implying production readiness.

Connect the reference to a real evidence pipeline

Keep three records conceptually separate: the eligible candidate, the allocation decision and the downstream delivery event. Give each a versioned relationship to the person, campaign and snapshot. Store the rule version and reason code with the allocation decision so a later policy change does not rewrite yesterday's explanation. In an internal system, control access to person identifiers and retain only what the approved purpose requires.

A practical shadow run reads a bounded historical snapshot, normalises it, applies both priority orders and compares the selected candidates with observed provider evidence. Do not write back to the sending platform during this exercise. Classify differences rather than hiding them in a single accuracy score: changed preferences, a concurrent campaign, delayed history, identity merges, expired offers or a failed downstream send each implies a different fix.

If a production implementation later reserves capacity, the engineering design needs atomic state changes, idempotent request handling and a documented policy for releasing failed reservations. A network timeout is not proof that no message was sent. Blind retries can duplicate contact; an overly conservative permanent reservation can suppress legitimate communication. This article intentionally leaves those policies to a reviewed provider-specific implementation instead of adding misleadingly simple sending code.

Test invariants before interpreting a campaign report

Start with a known answer, then remove all overlap. Priority should no longer matter. Next, make every overlap person already capped: again, priority must have no effect. Cap every person and selection must be zero. Empty audiences should produce zero counts and undefined percentages, not NaN or a reassuring 100% success figure. Reject an intersection larger than either audience and capped subgroup counts larger than their respective groups.

Generate valid disjoint groups, expand them into anonymous person rows and compare the replay with the aggregate calculator under both priorities. Check conservation for every fixture and verify that swapping A and B symmetrically swaps their allocations. Reversing input order must not change totals. Test missing fields, numeric strings, fractional people, non-finite values and values beyond the documented implementation limit.

The companion tests execute the exact displayed article code, not a separately maintained approximation. They also verify the prompt's reference arithmetic and the crawlable resource routes. These checks establish internal consistency and reproducibility. They cannot certify the completeness of a real history extract or the behaviour of an external provider under concurrency.

Choose the next decision, not just the next message

After reconciliation, the lifecycle owner still needs a customer-task rationale for priority. A time-sensitive activation step may deserve different treatment from a broad promotion, but that is a hypothesis to review, not a universal rule. Document who is accountable, what evidence would change the order and which customer-experience guardrails would stop the rollout.

If the question is whether a new priority policy improves retention or contribution, evaluate that policy with an appropriate experiment and an analyst-approved unit of randomisation. Keep assignment separate from delivery and avoid analysing only people who happened to receive the chosen campaign. A delivery-conditioned comparison can select different populations and erase the very allocation effect being tested. Use the existing assignment-to-analysis ledger guide when validating experiment populations.

Begin with the Campaign Collision Review Framework, reproduce your anonymous counts in the two-campaign calculator, and use the evidence-review prompt to turn unknowns into owned follow-up work. The useful outcome is an explainable allocation and a bounded next step—not a larger send count presented as growth.

View all growth marketing articles