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.

One 09:00 local wave per UTC offset bucket Eight offset buckets from plus ten to minus eight each fire at a different UTC hour, forming a diagonal across the day from 23:00 UTC for Sydney down to 17:00 UTC for Los Angeles. One 09:00 local wave per UTC offset bucket offset UTC clock 23:00 +10 Sydney 01:00 +8 Singapore 03:30 +5:30 Kolkata 07:00 +2 Berlin (DST) 09:00 0 Reykjavik 12:00 -3 Sao Paulo 14:00 -5 New York 17:00 -8 Los Angeles 00 04 08 12 16 20 24 UTC hour at which the wave is enqueued half-hour and quarter-hour zones need their own buckets — 24 is not enough
Each bucket is one job. The scheduler wakes every 15 minutes and asks which buckets are due.

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.

Skipped and repeated local hours at DST transitions At spring forward the local clock jumps from 01:59 to 03:00 so the 02:00 hour does not exist. At fall back the 01:00 hour occurs twice, once before and once after the offset change. DST makes a local wall-clock schedule ambiguous Spring forward — the local clock skips an hour 01:00 exists 01:59 exists 02:30 never occurs 03:00 exists 04:00 exists Rule: roll a skipped local time forward to the first instant that exists (03:00). Fall back — the local clock repeats an hour 00:30 once 01:30 first pass 01:30 second pass 02:30 once 03:30 once Rule: take the first pass, and dedupe on (subscriber, campaign, local date). store the IANA zone name, never a cached offset — offsets change twice a year
Both anomalies fall inside the small hours, which is exactly where badly scheduled marketing sends land.

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.

Throughput smoothing turns one spike into a steady rate Without smoothing the whole bucket is enqueued at once and far exceeds the push service rate limit, producing 429 responses. Jittering the same volume across thirty minutes keeps request rate below the limit. Smoothing turns one spike into a sustainable rate no smoothing — one wave at 09:00 jittered across 30 minutes off scale push service rate limit 429 responses, retries, late delivery steady request rate, no throttling give every bucket its own send window and jitter each subscriber inside it
The area under both shapes is the same volume; only the peak differs, and the peak is what gets throttled.

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.

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.