Setting Push Notification Frequency Caps
A frequency cap is two decisions wearing one name: which numbers you enforce, and what happens to the message you just refused to send. Teams argue about the first and forget the second, which is why capped programs so often produce silent campaign shortfalls nobody can explain.
Quick answer
Start with two windows per marketing tier — one short, one long — and enforce them as rolling windows over a Redis sorted set. Exempt transactional sends entirely. Suppressed messages are dropped by default, deferred only when they are still valuable later and their TTL can survive the wait.
| Category | Short window | Long window | On exceed |
|---|---|---|---|
| Transactional (passcodes, orders, security) | none | none | never suppressed |
| Account activity (mentions, watchlists) | 6 / 24 h | 20 / 7 d | defer |
| Marketing and lifecycle | 2 / 24 h | 5 / 7 d | drop |
| Digest and recommendations | 1 / 24 h | 1 / 7 d | collapse |
These are starting values, not truths. The number that matters is the crossover point in your own data where an additional send produces more unsubscribes than clicks, which the frequency and fatigue management guide shows how to measure. Ship the table above, then move each number one step at a time.
Daily, weekly, or rolling?
Three window shapes are in common use, and they are not interchangeable.
A fixed calendar window resets on a boundary: a counter keyed freq:u42:2026-07-26 with a midnight expiry. It costs one integer per subscriber per day and it is two Redis commands (INCR then EXPIRE). It is also the source of every burst problem described in the next section.
A fixed weekly window has the same shape with a longer period and a worse version of the same flaw — a weekly reset concentrates a week of held demand into whichever hour your schedulers wake up on Monday.
A rolling window asks a different question: not “how many have I sent since midnight” but “how many have I sent in the last 24 hours from right now”. It costs one sorted-set entry per send (a timestamp and a message id, a few dozen bytes) and it never has a boundary, because the boundary moves continuously with the clock.
Why calendar windows burst at midnight
The boundary problem has two halves and they compound.
The first is accuracy. With a cap of two per calendar day, a subscriber can receive two notifications at 23:45 and two more at 00:05 — four in twenty minutes, all fully compliant with the policy. The cap you wrote down is two; the cap the subscriber experiences is four. Rolling windows make this impossible by construction.
The second is synchronised release. A capped scheduler does not usually discard the demand it refused; it retries. Every held campaign, every deferred lifecycle touch, and every queued digest becomes eligible in the same instant the counter resets, and they all fire together. The subscriber gets a clump, and your send infrastructure gets a traffic spike that trips push-service rate limiting and produces a wave of 429 Too Many Requests right when you can least afford it — a problem the retry and backoff layer then has to absorb.
If you are stuck with calendar counters for now, the cheap mitigation is jitter: when a suppressed message becomes eligible again, delay it by a random offset spread over the first hour of the new window. That smooths the infrastructure spike but does nothing for the 23:45-plus-00:05 doubling, which only a rolling window fixes.
Per-topic caps versus one global cap
Both, in that order — and the global cap is the one that protects the subscriber.
A global cap counts every non-exempt message a user receives regardless of source. It is the only limit that reflects reality, because a subscriber does not experience your topics separately. Without it, five topics each capped at “one per day” produce five notifications a day.
A per-topic cap exists for a different reason: fairness between topics competing for the same global budget. If the price-drop watcher and the weekly digest both draw from a global budget of two, whichever job runs first wins every time, and the digest silently never ships. Per-topic caps reserve a slice so each topic gets a fair share of the whole.
Implement them as separate keys checked in sequence, most specific first, and only commit if every check passes:
// Topic caps are checked before the global cap; the global cap is the backstop.
function keysFor(userId, tier, topic) {
return [
{ key: `freq:${userId}:topic:${topic}:7d`, windowMs: 7 * 86400_000, cap: 2 },
{ key: `freq:${userId}:${tier}:24h`, windowMs: 86400_000, cap: 2 },
{ key: `freq:${userId}:global:24h`, windowMs: 86400_000, cap: 4 }
];
}
The topics themselves should come from a user’s declared preferences rather than your own taxonomy — see storing push notification topic preferences for the storage model. Where a user has set an explicit limit, that limit replaces your default and may only ever move the cap downward.
Exempting transactional sends
A cap that can suppress a one-time passcode is a defect, not a policy. Exemption must be structural rather than a conditional somebody can forget:
- The send API takes a required
tierfield. There is no default, so an unclassified message fails loudly in review rather than silently at 2 a.m. - Only
P0is exempt, and exemption means the governor skips counting altogether — the message is neither checked nor recorded against any window. Recording it “for visibility” is how a transactional send ends up consuming a marketing slot. - Exemption is not a licence. A message is transactional because the user’s own action caused it and they need it to complete a task, not because a marketer labelled it urgent. The boundary is legal as much as architectural; transactional versus marketing push notifications draws the line properly.
- Alert on any
P0suppression. It should be structurally impossible, so if the counter ever fires it means something is misclassified.
An atomic Redis sliding window
The check and the increment must be one operation. If you ZCARD and then ZADD from application code, two senders evaluating the same subscriber concurrently will both see the last free slot. The script below trims, counts, commits, and — the useful part — returns how long the caller must wait before a slot frees up, which is exactly the input the defer decision needs.
// slidingWindow.js — atomic check-and-increment with a retry hint
const SLIDING_WINDOW = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local cap = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
local used = redis.call('ZCARD', key)
if used >= cap then
-- score of the oldest surviving entry tells us when a slot frees up
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local freeAt = tonumber(oldest[2]) + windowMs
return {0, used, freeAt - now}
end
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, windowMs)
return {1, used + 1, 0}
`;
async function allow(redis, { key, windowMs, cap }, messageId) {
const [ok, used, retryAfterMs] = await redis.eval(
SLIDING_WINDOW, 1, key, Date.now(), windowMs, cap, messageId
);
return { allowed: ok === 1, used, retryAfterMs };
}
// Check every window; commit nothing unless all of them pass.
async function allowAll(redis, windows, messageId) {
const committed = [];
for (const w of windows) {
const verdict = await allow(redis, w, messageId);
if (!verdict.allowed) {
// roll back the windows we already consumed
await Promise.all(committed.map((c) => redis.zrem(c.key, messageId)));
return { allowed: false, blockedBy: w.key, retryAfterMs: verdict.retryAfterMs };
}
committed.push(w);
}
return { allowed: true };
}
module.exports = { allow, allowAll };
Two details matter. Using the message id as the sorted-set member makes the operation idempotent: a retry of the same logical notification re-scores the existing entry instead of consuming a second slot. And the rollback in allowAll prevents a partial commit — without it, a message blocked by the global cap would still have eaten its topic budget. If your window count is fixed and small, fold all of them into one script (as the parent guide does) and the rollback disappears entirely.
Drop, defer, or collapse?
Every suppressed message needs a disposition, and “throw an exception” is not one. The choice depends on whether the message still has value later, whether something newer supersedes it, and whether its TTL can survive the wait.
Drop is the default for marketing. A promotion that missed today’s slot has almost no value tomorrow, and deferring it just moves the fatigue problem forward a day. Log the user, the message, the campaign and the window that blocked it so the campaign’s shortfall is explainable.
Defer suits account-activity messages that stay relevant. Re-check the cap at release time rather than trusting the original verdict, cap the total wait with a deferMaxMs (six hours is a reasonable ceiling), and recompute TTL from the release moment — a message enqueued with TTL: 3600 and released five hours later is dead on arrival, which is one of the interactions covered in TTL and expiration handling. Deferral is also the mechanism quiet hours run on, so build the queue once and use it for both.
Collapse applies when several suppressed messages share a subject. Rather than three separate “your item shipped” notifications, hold them and emit one summary at the next open slot, reusing the same tag so the browser replaces rather than stacks. Collapsing turns a cap from something that deletes information into something that compresses it, which is why digest tiers should default to it.
Gotchas and edge cases
- Caps keyed by endpoint multiply by device count. A subscriber with a laptop and a phone has two endpoints and will receive two of everything. Key the counter on
user_idand fan out to endpoints only after the governor allows the send. - Retries must not consume a second slot. Use the stable message id as the sorted-set member so a redelivery re-scores the same entry. A push service that returns
429three times should not silently drain a subscriber’s daily budget. - A cap without a preference override drives revocations. Someone who finds two a day too many and has no dial to turn will use the browser’s dial instead, and permission revocation is close to irreversible. Expose the cap in your preference centre.
- Do not reset counters on unsubscribe-resubscribe. A user who churns and returns the same day should not get a fresh budget; key on the stable user id, not on the subscription row.
- Clock skew across senders corrupts a rolling window. Every sender writes
Date.now()into the same sorted set, so a host drifting by minutes can evict live entries or hold dead ones. Pass Redis’s ownTIMEinto the script, or keep hosts on NTP and alert on drift.
Related
- Back to Notification Frequency & Fatigue Management — the full send-rate governor, priority tiers, fatigue signals, and counter storage.
- Implementing quiet hours for push notifications — the other half of the governor, and the deferral queue this page relies on.
- Push engagement and campaign optimization — where caps sit relative to testing, segmentation, and analytics.
- Re-engagement campaign strategies — lifecycle sends are usually the largest consumer of a marketing budget.
FAQ
What is a safe default frequency cap if I have no data yet?
Two marketing notifications per rolling 24 hours and five per rolling 7 days, with transactional sends exempt. That combination is low enough that it will not damage a list while you gather data, and the weekly limit stops a daily cap from quietly permitting fourteen sends a week. Measure click-through and unsubscribe rates bucketed by trailing seven-day volume, then move one number at a time.
Should a frequency cap count notifications that were never displayed?
Count what you sent, not what was displayed. Display confirmation arrives asynchronously from the service worker, may never arrive at all, and cannot be waited on inside a send-path check. Counting accepted sends slightly over-counts real exposure, which errs in the subscriber’s favour. Track the display gap separately as a delivery-health metric rather than trying to reconcile it inside the cap.
Can a rolling window be implemented without Redis?
Yes, but the trade-offs get worse. A relational table of send timestamps with a DELETE of rows older than the window and a COUNT in the same transaction is correct, and the row lock gives you atomicity, but per-send write amplification and contention on a hot user become the bottleneck well before Redis would. An approximate alternative is two adjacent fixed counters weighted by how far you are into the current window, which removes most of the boundary doubling at a fraction of the storage cost.