Notification Frequency & Fatigue Management

Notification fatigue is the point at which each additional message costs you more reach than it earns. It is not a copywriting problem and it is not solved by better targeting alone — it is solved by a send-rate governor: a small, fast, mandatory checkpoint in the send path that knows how many notifications a given subscriber has already received, in which priority tier, and whether one more is allowed right now.

This guide builds that governor. It covers the four signals that tell you fatigue is already happening, a per-subscriber frequency budget expressed as rolling windows rather than calendar days, priority tiers that guarantee a one-time passcode is never suppressed by a marketing cap, the correct enforcement point, how the browser’s own notification budget interacts with what you do, and the storage model that makes the whole thing cost microseconds per send.

Problem statement

Most push programs discover fatigue backwards: click-through rate slides for a quarter, a copy experiment shows no lift, and only then does anyone graph sends-per-subscriber-per-week against unsubscribe rate. By then a measurable slice of the list has revoked permission, and revoked permission is close to permanent — the browser remembers the decision, the site cannot re-prompt, and recovery depends on the user re-enabling notifications by hand.

The structural cause is that frequency is an emergent property. No single team sends too much. The lifecycle job sends a win-back touch, merchandising sends a flash sale, product announces a feature, and the order pipeline sends three shipping updates — all on the same Tuesday, all individually justified, all landing on one person. Nothing in the architecture is aware of the total.

A governor makes the total someone’s responsibility. Its job is one answer with two outcomes: given a subscriber, a priority tier, and a timestamp, return allow or suppress, and if suppressing, say why. Everything below is about making that answer correct, fast, and atomic.

Prerequisites

1. The four signals that mean fatigue

Fatigue is measurable before it becomes churn. Track these four series bucketed by sends received in the trailing seven days, never as global averages — an average hides the fact that your top decile is being buried while the median subscriber gets one message a week.

Falling click-through rate at constant relevance. CTR varies by campaign, so the diagnostic is CTR within the same campaign type across frequency buckets. If your digest earns 6% from subscribers who received two notifications that week and 2.1% from those who received seven, the extra five messages are the variable, not the copy.

Rising unsubscribe rate. Preference-centre opt-outs are the polite signal: they rise smoothly with volume and are cheap to detect because they are your own event.

Permission revocations. The expensive signal. A user who flips notifications to Block leaves the addressable list without telling you — you keep getting 201 Created for a subscription that will never display anything again. Watch the ratio of accepted sends to display beacons per subscriber; a display rate that collapses to zero while acceptance stays healthy means a revocation or an OS-level mute.

Dismissals without interaction. The earliest signal and the most under-instrumented. The notificationclose event fires when a user actively dismisses a notification, and a rising close-to-click ratio means people still see you and choose not to engage. It precedes unsubscribes by weeks.

Fatigue signals plotted against weekly send volume As marketing notifications per subscriber per week rise from one to seven, click-through rate declines steadily while the unsubscribe and permission-revocation rate accelerates, the two curves crossing near five sends per week. Engagement decays faster than volume grows fatigue crossover high low 1 2 3 4 5 6 7 marketing notifications per subscriber per week click-through rate unsubscribes + permission revocations
Bucket every engagement metric by trailing-seven-day send count. The crossover point is where an extra send destroys more reach than it creates.

Plot these together and the cap stops being a matter of taste. The crossover — the volume at which the marginal notification produces more revocation than click — is your empirical ceiling, and it differs per segment: a cohort that clicked twice this week tolerates a higher budget than one that has not clicked in a month. Benchmarks for a healthy churn rate are in push unsubscribe rate benchmarks and churn.

2. The frequency budget: tiers before numbers

A single global cap is worse than no cap, because the first thing it does is suppress something important. A subscriber who has already received two marketing notifications today has their two-factor passcode dropped by a naive counter. The fix is to make the budget a function of priority tier, with higher tiers exempt from lower-tier accounting.

Four tiers cover almost every product:

  • P0 — Transactional. Passcodes, security alerts, payment failures, order and delivery state changes. Never capped, never deferred, never quieted. The boundary is legal as much as architectural, so read transactional vs marketing push notifications before classifying anything into it.
  • P1 — Account activity. Mentions, comments, shared documents, watchlist alerts. User-initiated in spirit but high-volume in practice: generous cap, deferred rather than dropped.
  • P2 — Marketing and lifecycle. Campaigns, promotions, win-back touches. Tight cap, dropped on exceed, logged.
  • P3 — Digest and optional. Roundups and recommendations. Lowest budget, and the natural target for collapsing several suppressed messages into one.

Each tier carries one or more windows. A single 2 per 24 h cap still permits fourteen marketing pushes a week, so pair a short window with a long one: 2 per 24 h and 5 per 7 d. Every window on the tier must pass, and a send consumes budget in its own tier plus every tier beneath it — otherwise a P1 flood leaves the P2 budget untouched and the subscriber still gets twelve notifications.

Priority tiers in the send-rate governor Four rows: P0 transactional is exempt and always delivered, P1 account activity allows six per twenty-four hours and defers, P2 marketing allows two per day and five per week and drops, P3 digest allows one per week and collapses. Priority tiers decide who gets capped Priority tier Rolling budget On exceeding cap P0 · Transactional passcodes, orders, security exempt always delivered P1 · Account activity mentions, watchlists 6 / 24 h defer to next slot P2 · Marketing campaigns, win-back 2 / 24 h and 5 / 7 d drop and log P3 · Digest roundups, suggestions 1 / 7 d collapse into next A send consumes its own tier and every tier beneath it; no lower tier can ever consume P0.
Tiers first, numbers second. The exemption for P0 is what makes an aggressive marketing cap safe to ship.

Picking the actual integers is a separate exercise with its own trade-offs — daily versus weekly versus rolling windows, per-topic versus global limits, and what a suppressed message should become. That is the subject of setting push notification frequency caps.

3. Enforce at send, not at display

There is a tempting shortcut: ship the counter to the service worker, let every push arrive, and decide client-side whether to call showNotification(). It is wrong in three separate ways.

First, it wastes the request — you have already paid for the encryption, the HTTP/2 stream, and the push service’s delivery attempt to a device that may be on metered data.

Second, and decisively, a push event that does not display a notification is charged against the browser’s budget. Chromium requires every push event to produce a visible notification; when the handler completes without one, the browser may display its own generic “site updated in the background” notice, and repeated offences cause Chromium to stop waking the service worker for that origin. Client-side filtering turns a suppressed marketing message into brand damage plus reduced delivery reliability for your transactional traffic.

Third, you lose the audit trail. Suppression in the service worker is invisible to your analytics; suppression in the governor is a row you can query when someone asks why a campaign under-delivered.

Where the frequency governor belongs in the send path The pipeline runs from campaign job to segment query to frequency governor to encryption and VAPID signing to the push service; the governor branches to a suppressed state, and a comparison panel contrasts correct send-time enforcement with incorrect service-worker filtering. The governor sits before the wire, not after it Campaign job candidate sends Segment query eligible users Frequency governor atomic check + commit Encrypt + sign aes128gcm · VAPID Push service 201 Created Suppressed drop · defer · collapse Delivered budget consumed Send-time enforcement versus display-time filtering At send — correct no wasted push-service request no browser budget consumed suppression reason logged server-side In the service worker — too late message already delivered and paid for skipping showNotification burns budget Chrome may show its own generic notice
Suppression is a server-side decision. By the time the service worker runs, the cost has already been incurred and the browser is watching.

4. The browser’s own budget and silent pushes

Your governor is not the only rate limiter in the system. Chromium maintains a per-origin push budget that accrues based on how much the user engages with your site. A push event that produces a visible notification is essentially free; one that does not spends budget. When the budget runs out, Chromium either surfaces its own background-update notification on your behalf or stops delivering to the origin’s service worker. Firefox permits only a small quota of consecutive silent pushes before unsubscribing the endpoint outright — a subscription killed that way starts returning 410 Gone.

Three practical consequences:

  • Never use a push message as a data-sync channel. Prefetching, cache warming, and analytics beacons do not justify a push, because you cannot spend one without either showing a notification or spending budget.
  • If a notification might be redundant, collapse it rather than suppressing the display. showNotification() with a tag matching an existing notification replaces it in place — one visible notification, budget intact.
  • Watch for a spike in 410 Gone after a silent-push deploy. A wave of retirements concentrated on Firefox endpoints is quota-triggered unsubscription, not churn.

The budget is engagement-weighted, so it is harsher on exactly the subscribers you are already fatiguing: a low-engagement user accrues slowly and is the most likely to have your service worker throttled. Frequency discipline and delivery reliability are the same problem from two ends.

5. Quiet hours as a governor input

Frequency answers “how many”; quiet hours answer “when not”. They belong in the same checkpoint because they share inputs and suppression vocabulary. A message that fails the quiet-hours predicate is rarely dropped — it is deferred until the window opens, so the governor needs a deferral queue and a re-check on release: a message held at 03:00 must still pass the frequency budget at 08:00.

Deferral also interacts with TTL. Carry the original TTL integer through a five-hour hold and it may already be close to expiring when the request finally reaches the push service. Recompute TTL at release rather than at enqueue, and let your TTL and expiration handling policy decide whether the message is still worth sending. Deriving the subscriber’s local time reliably is covered in implementing quiet hours for push notifications; picking a good hour rather than merely a non-terrible one is timezone-aware push notification scheduling.

6. Storage model for the counters

The governor runs on every send, so the counter store dominates its cost. Two designs work.

Redis sorted sets (recommended). One key per subscriber and tier, members are message ids, scores are epoch milliseconds. Trim everything older than the window, count what remains, compare against the cap, add the new member if allowed. ZREMRANGEBYSCORE + ZCARD + ZADD is three commands, and wrapping them in a Lua script makes check-and-commit atomic across concurrent senders. PEXPIRE to the longest window on every write so idle subscribers cost nothing.

A relational counters table. (user_id, tier, window_start, count) with an upsert per send. Easier to reason about and it survives a Redis flush, but a calendar window_start reintroduces fixed windows and their burst behaviour, and row-level contention on a hot user is real. Use it only at modest volume with the database already close to the sender.

Rolling window over a Redis sorted set Five send timestamps sit on a thirty-hour timeline; the two older than twenty-four hours are removed by ZREMRANGEBYSCORE, leaving three inside the window, which is compared against a cap of four before the new send is added. The window moves with the clock, not with midnight rolling window: the last 24 hours 24 h ago 18 h ago 12 h ago 6 h ago now trimmed ZREMRANGEBYSCORE ZCARD returns 3, the cap is 4, so this send is allowed ZREMRANGEBYSCORE freq:u42:p2:24h 0 (nowMs minus 86400000) ZCARD freq:u42:p2:24h → compare with the tier budget ZADD freq:u42:p2:24h nowMs messageId · PEXPIRE 86400000
A sorted set is a rolling window for free: trim by score, count what survives, then commit the new member in the same Lua script.

Step-by-step implementation

Step 1 — Declare the tier policy as data

Keep the policy in one place so it can be audited, overridden per user, and diffed in review. Windows are milliseconds; onExceed is the suppression verb.

// policy.js — the single source of truth for send-rate limits
const HOUR = 3600_000;
const DAY = 24 * HOUR;

const TIER_POLICY = {
  P0: { exempt: true },                                       // transactional: never capped
  P1: { windows: [{ ms: DAY, cap: 6 }], onExceed: 'defer' },
  P2: { windows: [{ ms: DAY, cap: 2 }, { ms: 7 * DAY, cap: 5 }], onExceed: 'drop' },
  P3: { windows: [{ ms: 7 * DAY, cap: 1 }], onExceed: 'collapse' }
};

// A send in tier T also consumes budget in every tier below it.
const CONSUMES = { P0: ['P0'], P1: ['P1'], P2: ['P1', 'P2'], P3: ['P1', 'P2', 'P3'] };

module.exports = { TIER_POLICY, CONSUMES, HOUR, DAY };

Step 2 — Make check-and-commit atomic

Two senders evaluating the same subscriber in the same millisecond must not both see “one slot left”. Do the trim, the count, and the insert inside a single Lua script so Redis executes them without interleaving.

// governor.js — atomic multi-window check-and-commit
const CHECK_AND_COMMIT = `
local now = tonumber(ARGV[1])
local member = ARGV[2]
local n = (#KEYS)
for i = 1, n do
  local windowMs = tonumber(ARGV[2 + i])
  local cap = tonumber(ARGV[2 + n + i])
  redis.call('ZREMRANGEBYSCORE', KEYS[i], 0, now - windowMs)
  if redis.call('ZCARD', KEYS[i]) >= cap then
    return {0, i}
  end
end
for i = 1, n do
  local windowMs = tonumber(ARGV[2 + i])
  redis.call('ZADD', KEYS[i], now, member)
  redis.call('PEXPIRE', KEYS[i], windowMs)
end
return {1, 0}
`;

async function checkAndCommit(redis, userId, tier, messageId, policy) {
  if (policy.exempt) return { allowed: true, reason: 'exempt' };
  const keys = policy.windows.map((w) => `freq:${userId}:${tier}:${w.ms}`);
  const args = [Date.now(), messageId,
    ...policy.windows.map((w) => w.ms),
    ...policy.windows.map((w) => w.cap)];
  const [ok, blockedIndex] = await redis.eval(CHECK_AND_COMMIT, keys.length, ...keys, ...args);
  return ok === 1
    ? { allowed: true }
    : { allowed: false, reason: `cap_${policy.windows[blockedIndex - 1].ms}ms` };
}

module.exports = { checkAndCommit };

The script returns the index of the window that blocked the send, which is what turns “suppressed” into an actionable log line: a message stopped by the seven-day cap needs a different response from one stopped by the daily cap.

Step 3 — Put the governor in the only send path

Every message — campaign, lifecycle, transactional — enters here. The VAPID keys come from the environment, never from source.

// send.js — the single funnel every message passes through
const webpush = require('web-push');
const { TIER_POLICY, CONSUMES } = require('./policy');
const { checkAndCommit } = require('./governor');

webpush.setVapidDetails(
  'mailto:push-ops@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function send(ctx, msg) {
  const policy = TIER_POLICY[msg.tier];

  for (const tier of CONSUMES[msg.tier]) {
    const verdict = await checkAndCommit(
      ctx.redis, msg.userId, tier, msg.id, TIER_POLICY[tier]
    );
    if (!verdict.allowed) {
      await ctx.log.suppression(msg, tier, verdict.reason, policy.onExceed);
      return { sent: false, action: policy.onExceed, reason: verdict.reason };
    }
  }

  // Payload stays well under the 4 KB ciphertext limit; aes128gcm is applied by web-push.
  const payload = JSON.stringify({
    title: msg.title, body: msg.body, tag: msg.collapseKey,
    data: { url: msg.url, tier: msg.tier, id: msg.id }
  });
  await webpush.sendNotification(ctx.subscription, payload, {
    TTL: msg.ttl ?? 86400,
    urgency: msg.tier === 'P0' ? 'high' : 'low'
  });
  return { sent: true };
}

Step 4 — Record every suppression as an event

A suppression you cannot query is a bug report waiting to happen. Write one row per suppressed message with the tier, the window that blocked it, and the verb applied.

CREATE TABLE push_suppressions (
  id           BIGSERIAL PRIMARY KEY,
  user_id      UUID        NOT NULL,
  message_id   TEXT        NOT NULL,
  tier         VARCHAR(4)  NOT NULL,   -- P0..P3
  blocked_by   TEXT        NOT NULL,   -- 'cap_86400000ms' | 'quiet_hours'
  action       VARCHAR(10) NOT NULL,   -- 'drop' | 'defer' | 'collapse'
  campaign_id  TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_suppressions_campaign ON push_suppressions (campaign_id, blocked_by);
CREATE INDEX idx_suppressions_user_time ON push_suppressions (user_id, created_at DESC);

Step 5 — Close the loop with a fatigue report

Run it weekly. If the top bucket shows materially worse click-through and worse unsubscribe behaviour than the middle buckets, lower the P2 cap by one and re-measure.

-- Engagement and churn by trailing-7-day send volume
SELECT LEAST(v.sends_7d, 8)                                  AS bucket,
       COUNT(DISTINCT v.subscriber_id)                       AS subscribers,
       COUNT(*) FILTER (WHERE e.event_type = 'accepted')     AS accepted,
       COUNT(*) FILTER (WHERE e.event_type = 'click')        AS clicks,
       COUNT(*) FILTER (WHERE e.event_type = 'unsubscribe')  AS unsubs
FROM (
  SELECT subscriber_id, COUNT(*) AS sends_7d FROM push_events
  WHERE event_type = 'accepted' AND created_at > NOW() - INTERVAL '7 days'
  GROUP BY subscriber_id
) v
JOIN push_events e ON e.subscriber_id = v.subscriber_id
                  AND e.created_at > NOW() - INTERVAL '7 days'
GROUP BY bucket
ORDER BY bucket;

Policy reference

Parameter Type Default Notes
tier enum P2 P0 transactional, P1 account activity, P2 marketing, P3 digest. Default to the most restrictive sensible tier so an unclassified message can never escape the cap.
windows[].ms integer (ms) 86400000 Rolling window length. Always pair a short window with a long one.
windows[].cap integer 2 Maximum sends allowed inside the window for this tier.
onExceed enum drop drop, defer, or collapse. Never send-anyway.
exempt boolean false true only for P0. An exempt tier skips counting entirely.
keyBy enum user_id Cap per user, not per endpoint, or multi-device users receive multiples.
quietHours object null { start: 22, end: 8 } in the subscriber’s local time; see the quiet-hours guide.
deferMaxMs integer (ms) 21600000 Longest a deferred message may wait before it is dropped instead.
userOverride object null Preference-centre caps. Always override downward; never let a default raise a user’s chosen limit.
failOpen boolean false If Redis is unreachable: false suppresses P2/P3 and passes P0/P1. Never fail open for marketing.

Verification

Confirm three behaviours before the governor touches production traffic.

# 1. Exhaust a P2 daily cap of 2 and confirm the third send is suppressed.
for i in 1 2 3; do
  curl -s -X POST http://localhost:3000/internal/send \
    -H 'content-type: application/json' \
    -d "{\"userId\":\"u42\",\"tier\":\"P2\",\"id\":\"m$i\",\"title\":\"t$i\"}" | jq -c .
done
# expect: {"sent":true} {"sent":true} {"sent":false,"action":"drop","reason":"cap_86400000ms"}

# 2. Confirm P0 still passes with the P2 budget fully consumed.
curl -s -X POST http://localhost:3000/internal/send -H 'content-type: application/json' \
  -d '{"userId":"u42","tier":"P0","id":"otp-1","title":"Your code"}' | jq -c .
# expect: {"sent":true}

# 3. Inspect the window contents directly.
redis-cli ZRANGE freq:u42:P2:86400000 0 -1 WITHSCORES

Then verify atomicity under concurrency: fire twenty parallel P2 sends for a fresh user with a cap of two and assert that exactly two return sent: true. If three or more succeed, the check and the commit are not in the same Lua script and you have a read-modify-write race.

Error and edge-case matrix

Condition Cause Fix
Transactional message suppressed Message classified into P2 by default because the caller omitted tier Make tier a required field on the send API; alert on any P0 suppression, which should be structurally impossible
Cap exceeded under load Check and commit issued as separate round trips Move both into one Lua script; the EVAL executes atomically
Multi-device user gets triple volume Counters keyed by endpoint Key on user_id; fan out to endpoints after the governor allows the send
Every send suppressed after a Redis restart Counters lost and treated as unknown rather than empty Treat an empty window as zero used; only fail closed on connection errors
Deferred message arrives expired Original TTL reused at release time Recompute TTL when the message is dequeued; drop it if the remainder is negative
410 Gone spike after a release Silent pushes exhausted the browser’s quota and the endpoint was unsubscribed Ensure every push event shows a notification; move suppression to the server

Cross-browser notes

Chromium enforces the visible-notification requirement most strictly and ties its budget to a site-engagement score, so the same silent-push mistake costs a low-engagement origin far more. Firefox’s Autopush allows a few consecutive pushes without a notification and then unsubscribes the endpoint permanently, surfacing in your pipeline as 410 Gone rather than as a warning. Safari and iOS add their own background budgeting on top of the OS notification summary, and iOS may batch your notifications into a scheduled summary regardless of when you sent them — so a carefully spaced cadence can still arrive as a clump. None of these vendor limiters replaces your governor; they stack on top of it, opaquely. Where users want finer control than your tiers offer, expose the caps through a preference centre so an over-messaged subscriber turns a dial instead of revoking permission.

FAQ

Should a retry consume a slot in the frequency budget?

No. A retry is the same logical notification as the original attempt, so it must reuse the original message id. Because the sorted set stores message ids as members, re-adding the same id updates its score instead of creating a second entry, and the count stays correct. Consuming a new slot per retry would let a flaky push service silently eat a subscriber’s entire daily budget.

Where should the frequency cap live if I use a third-party push vendor?

In your own service, before the vendor call. Vendor-side caps are opaque, apply only to messages routed through that vendor, and cannot see your transactional traffic if that goes out through a different path. Keep the governor in the one place every message crosses, and treat any vendor cap as a redundant backstop rather than the primary control.

What happens if Redis is unavailable when a send is evaluated?

Fail closed for marketing and digest tiers and fail open for transactional. A missing counter means you do not know how many notifications the subscriber has already received, and the cost of dropping a promotion is far lower than the cost of over-messaging someone into revoking permission. Emit a metric on every fail-closed decision so an outage is visible as a campaign shortfall rather than a mystery.

Do notification dismissals really predict unsubscribes?

They are the earliest reliable warning. The notificationclose event fires when a user actively dismisses rather than ignores a notification, and a rising close-to-click ratio within a segment consistently precedes opt-outs and permission revocations. Instrument it from the service worker alongside your display beacon so you can react before the subscriber makes a permanent decision.