Timezone-Aware Push Notification Scheduling
A single global send at 09:00 UTC reaches Berlin at 11:00, Los Angeles at 02:00 and Sydney at 20:00. This reference covers how to deliver at a chosen local hour for every subscriber without turning your sender into a once-a-day denial-of-service attack on the push services.
Quick Answer
Capture the subscriber’s IANA timezone identifier when they subscribe, group subscribers into offset buckets, and run one send wave per bucket at the UTC instant corresponding to the target local hour. Never store a fixed UTC offset — offsets change twice a year in most of the world.
| Decision | Do this | Not this |
|---|---|---|
| What to store | IANA name, e.g. Europe/Lisbon |
+01:00 or a minute count |
| How to schedule | one wave per offset bucket | one global send, one local send per user |
| Bucket granularity | 15-minute offsets (there are more than 24) | 24 whole-hour buckets |
| DST handling | resolve local time to UTC per zone, per run | cache the UTC instant for a year |
| Send shape | jittered window per bucket | everyone at the same second |
Capturing the Timezone at Subscribe Time
The browser already knows. Intl.DateTimeFormat().resolvedOptions().timeZone returns the IANA identifier — America/Sao_Paulo, Asia/Kolkata, Europe/Lisbon — and it is available in every browser that supports the Push API. Capture it in the same request that registers the subscription, so the record is complete from the moment it exists.
// client — register the subscription with its zone attached
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
const resolved = Intl.DateTimeFormat().resolvedOptions();
await fetch('/api/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription: sub.toJSON(),
timeZone: resolved.timeZone, // e.g. "Asia/Kolkata"
locale: resolved.locale,
capturedOffsetMinutes: -new Date().getTimezoneOffset()
})
});
Store the IANA name as the authoritative field and the numeric offset only as a derived, recomputed column. The offset is a fact about one instant; the zone name is a fact about the person. Keep a timezone_source column too — browser, account-setting, ip-inferred, default — because when a user complains about a 03:00 notification you need to know which of those produced it.
Refresh the zone opportunistically. A subscriber who moved from Lisbon to São Paulo keeps their old value until something updates it, so re-send the resolved zone on every authenticated page load and update the row when it differs. The same handler that manages pushsubscriptionchange re-registration is a natural place to piggyback this.
For subscribers with no zone — anonymous visitors on browsers that report a coarse value, or rows created before you added the field — fall back to a per-market default rather than UTC. UTC is nobody’s local time, and defaulting to it silently schedules your entire unknown segment at a European breakfast hour.
Bucketing by UTC Offset
Scheduling per subscriber does not scale: a million rows means a million timers. Scheduling per offset bucket does, because at any given instant the world contains only a few dozen distinct offsets. Compute each subscriber’s current offset, group by it, and emit one wave per group.
The target local hour of 09:00 maps to 09:00 - offset in UTC. Sydney at +10 fires at 23:00 UTC the previous day; India at +5:30 fires at 03:30 UTC; Los Angeles at -8 fires at 17:00 UTC. Sorted by offset, the waves form a diagonal across the UTC day.
A quarter-hourly tick is the simplest correct scheduler. It covers the 30-minute zones (India, parts of Australia) and the 45-minute ones (Nepal, the Chatham Islands) without special cases.
// scheduler tick — runs every 15 minutes
const OFFSET_SQL = `
SELECT id, endpoint, time_zone
FROM push_subscribers
WHERE active
AND EXTRACT(EPOCH FROM (now() AT TIME ZONE time_zone) - date_trunc('day', now() AT TIME ZONE time_zone)) / 60
BETWEEN $1 AND $2
`;
export async function tick(db, queue, campaign) {
const targetMinute = campaign.localHour * 60; // 09:00 -> 540
const rows = await db.query(OFFSET_SQL, [targetMinute, targetMinute + 14]);
for (const row of rows.rows) {
await queue.add('push-send', { subscriberId: row.id, campaignId: campaign.id }, {
delay: jitterMs(campaign.spreadMinutes) // see throughput smoothing
});
}
}
Letting PostgreSQL evaluate now() AT TIME ZONE time_zone keeps the DST rules in one place — the database’s own tzdata — rather than duplicating them in application code. Whichever layer owns the conversion, make sure its tzdata is patched; zone rules change several times a year and a stale image will schedule an entire country an hour out.
DST: The Hour That Does Not Exist and the Hour That Happens Twice
Local wall-clock time is not a continuous function. Twice a year most zones jump.
On a spring-forward date the local clock goes from 01:59 straight to 03:00. A job specified as “02:30 local” has no UTC instant to run at. Naive schedulers either skip it silently or throw. On an autumn fall-back date, local 01:00 to 01:59 happens twice — once at UTC offset before the change and once after — so a “01:30 local” job has two valid UTC instants and a naive scheduler will fire it twice.
Two defensive measures make this a non-event.
First, never schedule a campaign between 00:00 and 04:00 local. Every DST anomaly on Earth happens inside that band, so a 09:00 or 19:00 target never encounters one. This is also good practice for its own sake, and it composes with quiet hours for push notifications, which suppresses anything that would land overnight regardless of scheduling intent.
Second, make the send idempotent on (subscriber_id, campaign_id, local_date). A unique index on those three columns turns the fall-back double-fire into a harmless conflict rather than a duplicate notification. Compute local_date in the subscriber’s zone, not the server’s.
CREATE UNIQUE INDEX push_send_once_per_local_day
ON push_sends (subscriber_id, campaign_id, local_date);
-- the sender claims the slot before it calls the push service
INSERT INTO push_sends (subscriber_id, campaign_id, local_date, state)
VALUES ($1, $2, ($3::timestamptz AT TIME ZONE $4)::date, 'claimed')
ON CONFLICT DO NOTHING
RETURNING id;
If the insert returns no row, another pass already claimed it — skip the send. This same guard also protects against a scheduler restart replaying a tick, which is a far more frequent cause of duplicates than DST.
Throughput Smoothing
Bucketing solves correctness and creates a new problem: the largest bucket is enormous. If 40% of your list sits in one offset, 09:00 local becomes a single instantaneous burst against a handful of push service endpoints, and the push services answer with 429 Too Many Requests and Retry-After headers.
Spread each bucket over a window you choose per campaign — 20 to 40 minutes is comfortable for a daily digest, 2 to 5 minutes for something time-sensitive. Derive the offset deterministically from the subscriber id so a retry lands in the same slot as the original attempt rather than drifting.
import crypto from 'node:crypto';
// deterministic jitter: same subscriber always gets the same slot in the window
export function jitterMs(subscriberId, campaignId, spreadMinutes) {
const h = crypto.createHash('sha256').update(`${subscriberId}:${campaignId}`).digest();
const fraction = h.readUInt32BE(0) / 0xffffffff;
return Math.floor(fraction * spreadMinutes * 60_000);
}
Cap the sender’s egress rate independently of the jitter. Jitter shapes the average; a token bucket in front of the HTTP client bounds the peak when several campaigns overlap. When the push service still pushes back, honour Retry-After as described in handling 429 Too Many Requests from push services rather than retrying immediately.
Interaction With TTL and Frequency Caps
Timezone scheduling changes two settings you may already have tuned.
TTL becomes narrower, not wider. The point of sending at 09:00 local is that 09:00 local is the right moment. If the device is offline and the message sits in the push service queue, a long TTL means it arrives at 16:00 local and no longer makes sense. For a locally-scheduled digest, a TTL of a few hours is usually better than 24 hours: better to drop the message than to deliver it at the wrong local hour. The trade-offs are set out in setting optimal TTL values for time-sensitive alerts.
Frequency caps must count in local days. A cap of “two notifications per day” evaluated against UTC dates gives a Sydney subscriber a fresh allowance in the middle of their afternoon, and gives a Los Angeles subscriber one at 16:00. Evaluate the cap window in the subscriber’s own zone, using the same local_date you used for the idempotency key. The counter design is covered in setting push notification frequency caps.
Because both settings key off the same local calendar, keep one helper that converts an instant plus a zone into a local date, and use it for scheduling, capping and reporting alike. Segmentation logic that mixes local and UTC days will produce cohorts that disagree with the send log — a debugging session nobody enjoys. Behavioural cohorts built the right way are covered in segmenting push subscribers by behaviour.
Gotchas & Edge Cases
- There are more than 24 offsets. Nepal is +5:45, the Chatham Islands are +12:45, and Lord Howe Island shifts by 30 minutes rather than an hour at DST. A 24-bucket scheduler is wrong for millions of people.
- Stale tzdata is a silent bug. Zone rules change by government decree, sometimes weeks in advance. Pin tzdata updates into your deploy pipeline and alert if the database and the application disagree about a known transition.
- The captured offset is not the current offset. A row captured in July at +2 is at +1 in January. Recompute from the zone name at send time; never read the stored offset.
- VPNs and travel produce implausible zones. A subscriber whose reported zone flips daily between two continents is behind a VPN. Prefer the account setting when one exists, and treat rapid zone changes as low-confidence.
- Backfill carefully. Adding the zone column to an existing table leaves every historical subscriber null. Sending them all at UTC 09:00 is a spike and a bad local hour; assign a market default and refresh on next visit instead.
- Test the transition, not the steady state. Run the scheduler against a frozen clock set to 01:30 on both DST dates for a zone that observes them. A test suite that only runs on ordinary days will never see either anomaly.
Related
- Back to Push Personalization & Segmentation — the targeting layer that decides who receives a locally-scheduled send.
- Segmenting push subscribers by behaviour — build the cohorts each wave draws from.
- Implementing quiet hours for push notifications — the suppression layer that catches anything scheduling gets wrong.
- Setting optimal TTL values for time-sensitive alerts — pick a TTL that expires before the local hour stops being right.
FAQ
Should I store a UTC offset or an IANA timezone name?
Store the IANA name such as Europe/Lisbon. An offset is only true for one instant, so a value captured in summer is wrong all winter. Recompute the offset from the zone name at send time, and treat any stored offset as a derived column you can rebuild.
How do I stop a daylight-saving change from sending a notification twice?
Add a unique index on subscriber id, campaign id and the local date computed in the subscriber’s own zone, and have the sender claim that row before it calls the push service. The repeated local hour at fall back then produces a harmless conflict instead of a second notification. Avoiding the 00:00 to 04:00 local window entirely removes the situation altogether.
How wide should the send window for one timezone bucket be?
Wide enough that your peak request rate stays under what the push services accept, and narrow enough that the notification still feels scheduled. Twenty to forty minutes suits a daily digest; two to five minutes suits something time-sensitive. Derive each subscriber’s offset inside the window by hashing their id so retries land in the same slot.