zylior
← Blog

Send windows by timezone: hit the inbox at the right moment

"We send at 9am" — fine, but whose 9am? If your list has subscribers in Paris, Montreal and Singapore, a single "9am" send hits 9am for one timezone and 3am for another. Here's how to compute the send window in each person's real timezone, without getting burned by daylight saving time.

The problem: "9am" isn't a time, it's a timezone

When you schedule a campaign "at 9am", your tool interprets that in its timezone (often UTC, sometimes your Coolify server's, sometimes your personal one). For a subscriber in `America/Los_Angeles`, a send pinned to 9am `Europe/Paris` lands at midnight for them. Pin it to 9am UTC in winter, and it's 1am in Paris, 4am in Dubai.

The right goal isn't "send at moment T" but "get the email to arrive between 8am and 10am local time for each subscriber". That means a different send moment per timezone, computed backwards from the target local time.

The open-rate gap between "good slot" and "3am" is not marginal. An email delivered at night gets buried under 20 others by wake-up, and the more unopened sends you pile up, the more your sender reputation degrades (Gmail/Yahoo read engagement). Timing protects deliverability, not just today's open rate.

Computing the window: local time first, UTC instant second

The golden rule: you store and compare always in UTC, but you reason in local time. The correct pipeline is: (1) you start from the target local time, e.g. 9am; (2) you take the subscriber's IANA timezone, e.g. `Europe/Paris`; (3) you convert "9am local today" into a UTC instant; (4) you push that instant into your send queue.

The deadly trap is freezing the offset. `Europe/Paris` is NOT "UTC+1": it's UTC+1 in winter and UTC+2 in summer (daylight saving time / DST). If you hardcode `+1`, half the year you send an hour too early. The only correct way is to go through the IANA timezone name and let the lib handle the DST switch at the relevant date.

// Node 18+, zéro dépendance : Intl gère IANA + DST nativement
// On veut : instant UTC où il sera `targetHour` heure locale chez l'abonné
function sendInstantUTC(tz, targetHour, baseDate = new Date()) {
  // Heure locale actuelle dans tz (gère DST automatiquement)
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone: tz, hour12: false,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
  });
  const p = Object.fromEntries(
    fmt.formatToParts(baseDate).filter(x => x.type !== 'literal')
       .map(x => [x.type, x.value]));
  // décalage tz = (heure murale locale) - (heure UTC) à cet instant
  const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day,
                         +p.hour, +p.minute, +p.second);
  const offsetMs = asUTC - baseDate.getTime();
  // instant UTC pour targetHour:00 heure locale, le jour local courant
  const localTargetUTC = Date.UTC(+p.year, +p.month - 1, +p.day, targetHour, 0, 0);
  return new Date(localTargetUTC - offsetMs);
}

sendInstantUTC('Europe/Paris', 9);        // 9h Paris -> ~07:00Z (été) / 08:00Z (hiver)
sendInstantUTC('America/Los_Angeles', 9); // 9h LA -> ~16:00Z (été) / 17:00Z (hiver)
Don't reinvent the DST calculation by hand. `Intl.DateTimeFormat` with `timeZone` (Node, Deno, browsers) or a lib like `luxon`/`Temporal` knows the IANA database and switches summer/winter time on the right date. The homemade "fixed offset" code is THE bug that slips through review and breaks twice a year.

Spreading the send: don't blast a whole timezone at once

Once the instants are computed, you end up with batches: all your `Europe/Paris` subscribers at 07:00Z, all your `America/New_York` at 13:00Z, etc. If each batch goes out in one burst, you take two risks: throttling on the provider side (Resend, SES) and a spike of opens/unsubscribes that looks like spam. Spread each window out.

The trap of missing or frozen timezones

You'll never have the timezone for 100% of your list. Two bad reflexes: (1) applying your own timezone to everyone — you'll send at 3am to half the planet; (2) freezing the timezone at signup and never refreshing it — someone moves, travels, or you detected it wrong.

Clean fallback strategy, in order of reliability: timezone declared by the subscriber > timezone inferred from recent activity (most frequent open/click timestamp) > timezone inferred from the country (signup field, signup IP). As an absolute last resort, an explicit default — typically the timezone of the bulk of your list — and never before 8am or after 9pm in that default. Better an email at 8am for an approximate timezone than an email guaranteed at 3am.

Store the IANA name (`Europe/Paris`), never the offset (`+02:00`) nor an abbreviation (`CET`, ambiguous and not DST-aware). The offset goes wrong at the next DST switch; the IANA name stays correct forever because the lib recomputes the offset at send time.

To start tomorrow: add an IANA `timezone` field to your subscribers, move your scheduler from "single UTC instant" to "target local time + per-timezone conversion", set the window to 8am–10am local with jitter, and log the timezone used + its source (declared/inferred/default) for each send. You'll see opens climb on the timezones you were massacring without knowing it — and you'll have the trail to prove it's not luck.

The newsletter

By subscribing you agree to receive the Zylior newsletter. One-click unsubscribe in every email.