Implementing Quiet Hours for Push Notifications
A notification that wakes someone at 03:00 does not just fail to convert — it converts them into a person who blocks your origin. Quiet hours are the cheapest reach protection you can build, and almost all of the difficulty is in one unglamorous question: what time is it where this subscriber is?
Quick answer
Store an IANA timezone identifier (Europe/Berlin, not +02:00) per subscriber, resolve it in strict order of reliability, and evaluate the quiet-hours predicate in the send path next to your frequency cap. A message that lands inside the window is not dropped — it is queued with an absolute release instant, and both its cap and its remaining time-to-live are re-checked when the window opens.
| Decision | Default |
|---|---|
| Window | 22:00–08:00 in the subscriber’s local time |
| Timezone format | IANA identifier, resolved through Intl at evaluation time |
| Exempt tiers | Transactional only (P0) |
| Suppressed action | Defer to the next window opening, never drop silently |
| Maximum hold | 10 hours; longer than one quiet window means something is wrong |
| TTL | Recomputed from an absolute expiresAt at release, never carried over |
Why a single send time is four different local times
Campaign schedulers think in absolute instants; subscribers experience wall-clock time. A job that fires at 09:00 UTC reaches a subscriber in Los Angeles at 02:00 and one in Tokyo at 18:00, from the same queue, in the same second.
The instinctive fix — bucket subscribers by timezone and run the job once per bucket — solves scheduling but not enforcement. Triggered messages, retries, and deferred sends do not arrive on the campaign’s schedule, so the quiet-hours predicate still has to live in the send path. Picking a good hour rather than merely a non-terrible one is a scheduling problem, covered in timezone-aware push notification scheduling; this page is about the guard rail underneath it.
Deriving the subscriber’s local time
Three sources, in this order. Never invert the order to get a “fresher” answer.
1. A stored IANA timezone the user confirmed. A profile setting or an explicit quiet-hours preference. This is the only source with intent behind it, so it wins unconditionally. It goes stale only when someone relocates permanently and never updates their profile.
2. Intl.DateTimeFormat().resolvedOptions().timeZone, captured at subscribe time. The browser reports the OS timezone as an IANA identifier — the single best automatic signal available, because it comes from the device the notification will land on. Its weakness is that it is a snapshot: capture it once during the subscription flow and a subscriber who moves is served their old zone forever. Re-capture it opportunistically on every session and update the row when it changes.
3. IP geolocation, inferred at send time. The fallback of last resort. Corporate VPNs, mobile carrier gateways, and carrier-grade NAT routinely place users on the wrong continent, and unlike the first two sources you cannot tell a wrong answer from a right one. Use it only to pick an initial default, and mark the row so a better source can overwrite it.
If all three fail, do not guess. Fall back to UTC combined with a deliberately conservative window — the notification waits rather than gambling.
Capture the zone in the same request that registers the subscription, so the two can never disagree:
// client: send the browser's own IANA zone with the subscription
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(window.VAPID_PUBLIC_KEY)
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscription: subscription.toJSON(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // "Europe/Berlin"
timeZoneSource: 'browser',
capturedAt: new Date().toISOString()
})
});
Server-side, resolution is a short precedence chain:
// Highest-confidence source wins; never let a guess overwrite a preference.
function resolveTimeZone(sub) {
return sub.profileTimeZone // 1. user confirmed it
?? sub.browserTimeZone // 2. Intl at subscribe / last session
?? timeZoneFromIp(sub.lastSeenIp) // 3. inference, marked as low confidence
?? 'UTC'; // 4. guard: widest quiet window applies
}
DST-safe scheduling
Storing +02:00 instead of Europe/Berlin is the classic failure. A fixed offset is correct for part of the year and an hour wrong for the rest, so every spring and autumn a batch of subscribers starts receiving notifications an hour inside their quiet window — a bug that is invisible in testing and hard to attribute afterwards.
Two rules make this safe. Store the identifier, not the offset, and resolve the offset with Intl at the instant you actually evaluate the predicate. And when you compute a future release instant, re-resolve the offset at that future instant: a message deferred at 01:00 on a transition night to a target of 08:00 local crosses a boundary where the offset changes underneath you.
// Local wall-clock parts for an instant in a named zone.
function localParts(date, timeZone) {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const out = Object.create(null);
for (const part of fmt.formatToParts(date)) out[part.type] = part.value;
return out;
}
// Offset of the zone at this specific instant, in milliseconds.
function offsetMsAt(date, timeZone) {
const p = localParts(date, timeZone);
const asIfUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute);
return asIfUtc - Math.floor(date.getTime() / 60000) * 60000;
}
function isQuiet(date, timeZone, startHour, endHour) {
const hour = +localParts(date, timeZone).hour;
return startHour > endHour
? hour >= startHour || hour < endHour // window wraps past midnight
: hour >= startHour && hour < endHour;
}
// The next instant at which local time reaches endHour, DST-safe.
function nextWindowOpen(date, timeZone, endHour) {
const p = localParts(date, timeZone);
const offsetNow = offsetMsAt(date, timeZone);
let target = Date.UTC(+p.year, +p.month - 1, +p.day, endHour, 0) - offsetNow;
if (target <= date.getTime()) target += 86_400_000;
// Re-resolve: the offset at the target may differ from the offset now.
const drift = offsetMsAt(new Date(target), timeZone) - offsetNow;
return target - drift;
}
The startHour > endHour branch in isQuiet is not decoration. A 22:00–08:00 window wraps past midnight, and the naive hour >= 22 && hour < 8 never evaluates true — which produces a quiet-hours feature that silently does nothing, the most common bug in this whole area.
Deferring until the window opens
Quiet hours share their suppression machinery with the frequency governor described in the frequency and fatigue management guide, but the default disposition is different: a capped marketing message is usually dropped, while a quieted message is almost always deferred. The subscriber still wants it; they just do not want it now.
A sorted set keyed by release instant is all the queue you need. Score by the absolute epoch millisecond at which the window opens, drain what is due, and claim each entry with ZREM so two workers cannot both send it.
// Defer a quieted message to the exact instant its window opens.
async function defer(redis, msg, timeZone, quiet) {
const releaseAt = nextWindowOpen(new Date(), timeZone, quiet.endHour);
if (releaseAt - Date.now() > msg.deferMaxMs) return { action: 'drop', reason: 'hold_too_long' };
const record = JSON.stringify({
id: msg.id, userId: msg.userId, tier: msg.tier, timeZone,
title: msg.title, body: msg.body, url: msg.url,
expiresAt: msg.expiresAt, // absolute deadline, not a TTL integer
deferredAt: Date.now()
});
await redis.zadd('push:deferred', releaseAt, record);
return { action: 'defer', releaseAt };
}
// Drain due messages; re-check everything that could have changed while waiting.
async function releaseDue(redis, ctx, now = Date.now()) {
const due = await redis.zrangebyscore('push:deferred', 0, now, 'LIMIT', 0, 500);
for (const raw of due) {
if (await redis.zrem('push:deferred', raw) === 0) continue; // claimed elsewhere
const msg = JSON.parse(raw);
const ttl = Math.floor((msg.expiresAt - now) / 1000);
if (ttl <= 60) { await ctx.log.suppression(msg, 'ttl_exhausted'); continue; }
if (isQuiet(new Date(now), msg.timeZone, ctx.quiet.startHour, ctx.quiet.endHour)) {
await defer(redis, msg, msg.timeZone, ctx.quiet); // zone changed under us
continue;
}
const verdict = await ctx.governor.check(msg.userId, msg.tier, msg.id);
if (!verdict.allowed) { await ctx.log.suppression(msg, verdict.reason); continue; }
await ctx.send({ ...msg, ttl });
}
}
Three re-checks happen at release and all three are load-bearing. The TTL may have run out while the message sat in the queue. The timezone may have been updated by a session that happened during the hold, moving the window. And the frequency cap verdict from hours ago is stale — the subscriber may have received three other notifications since, so the message must pass the governor again before it goes out.
TTL: the interaction that eats deferred messages
TTL is a relative header the push service applies from the moment it receives the request, so a deferred message is not automatically doomed. The bug is upstream: campaign code computes TTL: 3600 when the message is created, that integer rides through the queue, and five hours later it is sent verbatim — the push service happily accepts it and the message is now valid for an hour starting from a point at which it is already stale content.
Model lifetime as an absolute expiresAt on the message and derive TTL as (expiresAt - now) / 1000 at the moment of the actual request. If the result is non-positive, or below a floor of about sixty seconds, drop the message and log it rather than sending something that will expire on the wire. For messages that should survive a long hold, raise expiresAt rather than the TTL integer — the TTL and expiration handling guide covers what values the push services actually honour.
A related trap: urgency: 'low' combined with a deferred send. Low urgency lets the push service hold the message further to save battery, stacking its delay on top of yours. For anything that must land close to the window opening, use normal urgency at release even if the original send was low.
Per-user overrides and exemptions
Quiet hours are a default, not a law. Three overrides are worth supporting.
User-set windows. Night-shift workers have inverted schedules, and a preference centre that lets someone set their own start and end hour turns a source of complaints into a source of trust. Store the override alongside the timezone and let it replace the default entirely; the frequency caps they choose belong in the same record.
Transactional exemption. P0 messages bypass quiet hours for the same reason they bypass frequency caps: the user’s own action caused them and delay makes them useless. A passcode at 03:00 is exactly what the user asked for. Everything else waits.
Explicit opt-in to urgent alerts. Breaking-news, security, and outage notifications need a separate consent that a subscriber grants deliberately, per topic — the pattern is described in the breaking-news push alert architecture. Never infer this exemption from engagement; make the user choose it.
Gotchas and edge cases
- A wrapping window that never matches.
hour >= 22 && hour < 8is always false. Quiet hours that “do nothing in production” are almost always this line. - Storing offsets instead of identifiers.
+02:00is right for half the year. StoreEurope/Berlinand resolve throughIntlat evaluation time. - The release stampede. Every message deferred overnight becomes eligible at 08:00 local, and a whole timezone’s worth fires at once. Spread the release with a random offset over the first fifteen to thirty minutes of the window.
- Server-local time leaking in.
new Date().getHours()returns the server’s hour. Any use of a local-time method without an explicittimeZoneis a bug; ban them in review. - A deferred message outliving its campaign. Cap the hold with
deferMaxMsand drop anything that would wait longer than a single quiet window. If a message needs a twelve-hour hold, it needed a different schedule. - Nothing recorded about why. Log the deferral, the computed release instant, and the resolved zone with its source. When a subscriber reports a 3 a.m. notification, that row is the only way to tell a resolution failure from an exemption working as designed.
Related
- Back to Notification Frequency & Fatigue Management — the send-rate governor that quiet hours plug into, plus priority tiers and fatigue signals.
- Setting push notification frequency caps — the numbers, rolling windows, and the drop-defer-collapse decision this page reuses.
- Timezone-aware push notification scheduling — choosing the best hour to send once you know it is not a forbidden one.
- TTL and expiration handling — what push services do with the TTL header and which values they honour.
- Push engagement and campaign optimization — where timing sits among the other engagement levers.
FAQ
Should transactional notifications respect quiet hours?
No. A one-time passcode, a fraud alert, or a payment failure is caused by the user’s own action and is worthless delayed, so the transactional tier bypasses both quiet hours and frequency caps. The discipline is in classification, not in the exemption: a message is transactional because the user needs it to complete something they started, not because a campaign owner marked it urgent. Alert on any transactional message that gets deferred, because it means something is misclassified.
What should I do if a subscriber has no usable timezone?
Fall back to UTC with a deliberately conservative window and mark the row as low confidence so a better source can overwrite it later. Do not guess from language headers or currency — those correlate with country, not with the device’s clock. The safest fallback behaviour is to defer rather than to send, because a late notification costs a click while a 3 a.m. notification can cost the permission entirely.
Why not just filter quiet hours inside the service worker?
Because the message has already been delivered by then. A push event that completes without calling showNotification() consumes the browser’s push budget and may trigger Chrome’s own background-update notice, and repeated offences reduce delivery reliability for the origin. Quiet hours must be evaluated server-side, in the send path, before the request reaches the push service.