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.
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)
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.
- Window, not instant: aim for 8am–10am local, not 9am sharp. That gives you 120 minutes to distribute across.
- Per-subscriber jitter: add a stable random offset, e.g. `hash(email) % 90` minutes, to smooth things out without reordering on every run.
- Rate cap: cap at what your provider tolerates (Resend free ~2 req/s, SES depending on your quota). The queue drains at a constant rate.
- Idempotence: a `campaign+subscriber` key so a retry never sends twice — a worker crash in the middle of a batch must not double the sends.
- Avoid 0am–6am local: if a subscriber has no known timezone, do NOT guess — see the next section.
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.
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.