Send time should follow the recipient’s clock
Schedule cold email against the recipient’s local clock, not the sender’s. Resolve each recipient to an IANA time zone identifier such as America/New_York — never to a stored UTC offset, which is wrong on one side of every daylight-saving transition. Validate the identifier against Intl.supportedValuesOf("timeZone"), then derive local hour and weekday with Intl.DateTimeFormat.formatToParts rather than by adding an offset to a timestamp.
A campaign that fires at 09:00 in your window lands at 03:00 for a recipient six zones west. It is delivered. It is authenticated. Nobody reads it. By the time that person opens their client, every message that arrived between 03:00 and their first coffee is stacked above it in a reverse-chronological list. Nothing about the send failed; the timing did.
That is an argument from how mail clients order a list, not a measured effect, and we are not going to quote a lift number at you. What follows is the engineering: working out which clock a recipient is on, storing it so it stays right across daylight saving, and evaluating a working-hours window without the arithmetic bug that every first implementation writes.
Why does send time have to follow the recipient’s clock?
Because the only clock that governs when a message is seen is the one on the recipient’s desk. Your send window encodes an assumption about when a human is at work. Applied to a mailbox in another zone, that assumption is simply transplanted to the wrong hours — the window still spans eight hours, it is just eight hours of somebody else’s night.
A second-order effect: the Date header carries your offset, not theirs. RFC 5322 says the date-time field "SHOULD express local time", with a numeric zone indicating whether the time is "ahead of (i.e., east of) or behind (i.e., west of) Universal Time". Their client converts it before display. The recipient never sees your 09:00. They see 03:00.
The principle is not novel — it is written into US telemarketing regulation. The FTC’s Telemarketing Sales Rule, 16 CFR 310.4(c), makes it abusive, absent the called person’s prior consent, to call a residence "at any time other than between 8:00 a.m. and 9:00 p.m. local time at the called person’s location". That governs telephone calls, not email, and this article is not legal advice. It is quoted only as a regulator putting in writing whose clock counts.
How do you resolve a recipient’s time zone from the data you already have?
From a CSV import you usually have some subset of country, city, region and a company website. That is enough for most rows. Work down this order and stop at the first hit:
- An explicit zone on the record, if the source provided one. Validate it before trusting it (see below).
- City plus country, looked up in a gazetteer that carries a tz field.
- Latitude and longitude, if you have them, resolved by point-in-polygon against time zone boundaries.
- Country alone — but only for the countries that have exactly one zone.
- Nothing. Fall back to an explicit default and mark the row, rather than guessing.
Country alone is the interesting case, because whether it works is a property of the country, and the tz database itself publishes the answer. The file zone1970.tab lists "a table where each row stands for a timezone where civil timestamps have agreed since 1970", with column 1 holding the ISO 3166 codes of the countries that overlap that zone. Count the rows per country code and you have an exact list of the countries you may not resolve from country alone.
$ curl -s https://data.iana.org/time-zones/tzdb/zone1970.tab \
| awk -F'\t' '!/^#/ && NF { split($1, c, ","); for (i in c) n[c[i]]++ }
END { for (k in n) if (n[k] > 1) print n[k], k }' \
| sort -rn | head -8
29 US
27 RU
23 CA
16 BR
13 AU
12 MX
12 AR
11 AQRun that over the whole file and 33 of the 247 country codes in zone1970.tab appear on more than one row, out of 312 rows total. Some of those 33 are not live ambiguity: Germany appears twice only because of Büsingen, a German exclave inside Switzerland that follows Europe/Zurich, and both zones keep the same civil time today. For the US, Russia, Canada, Brazil, Australia, Mexico, Argentina, Kazakhstan, Indonesia, Chile, Portugal and Spain the split is real and current — Spain’s Canary Islands sit an hour behind Madrid, and Portugal’s Azores an hour behind Lisbon.
| Code | Rows | Resolvable from country alone? |
|---|---|---|
| US | 29 | No — needs state or city |
| RU | 27 | No — needs region |
| CA | 23 | No — needs province or city |
| BR | 16 | No |
| AU | 13 | No — needs state |
| MX | 12 | No |
| AR | 12 | No |
| KZ | 7 | No |
| ES | 3 | No — mainland vs Canary Islands vs Ceuta/Melilla |
| PT | 3 | No — mainland vs Madeira vs Azores |
| DE | 2 | In practice yes — Europe/Berlin (the second row is the Büsingen exclave) |
| GB, FR, NL, IN, JP, SG | 1 each | Yes |
City and coordinate lookups
For the multi-zone countries you need something below country level, and two public datasets do the job. GeoNames publishes city dumps — cities15000 is "all cities with a population > 15000 or capitals", cities1000 goes down to population 1000 — whose geoname table includes a timezone column carrying the IANA identifier directly. It is CC BY 4.0, with no warranty of accuracy. If you have coordinates instead, timezone-boundary-builder publishes polygons each tagged with one identifier; the output is ODbL and OpenStreetMap-derived, and its README is candid about "a few guesses on where to draw an arbitrary border".
One more source worth knowing if your prospects run Microsoft 365: Windows and Exchange do not use IANA names, they use display strings like "W. Europe Standard Time". CLDR publishes the mapping in windowsZones.xml, and the mapping is territory-dependent, not one-to-one:
<mapZone other="W. Europe Standard Time" territory="001" type="Europe/Berlin"/>
<mapZone other="W. Europe Standard Time" territory="AT" type="Europe/Vienna"/>
<mapZone other="W. Europe Standard Time" territory="CH" type="Europe/Zurich"/>
<mapZone other="W. Europe Standard Time" territory="DE" type="Europe/Berlin Europe/Busingen"/>
<mapZone other="Eastern Standard Time" territory="001" type="America/New_York"/>
<mapZone other="Eastern Standard Time" territory="CA" type="America/Toronto America/Iqaluit"/>The territory="001" row is the world-wide default and is the one to use when you know the Windows name and nothing else. Everything else needs the country code as well.
Why an IANA name and not a UTC offset?
Because an offset is a measurement and a zone is a rule. "UTC-5" is what New York happened to be doing on the day you looked; America/New_York is the whole table of what it has done and will do. MDN puts it plainly: "Avoid using offset identifiers if there is a named time zone you can use instead. Even if a region has always used a single offset, it is better to use the named identifier to guard against future political changes to the offset."
Those changes are not hypothetical. IANA describes the database as "updated periodically to reflect changes made by political bodies to time zone boundaries, UTC offsets, and daylight-saving rules". RFC 6557 — the February 2012 BCP that moved maintenance to the IETF — put the rate at "approximately twenty times per year, depending on the year"; the tagged-release cadence since is slower than that, three in 2025 and three so far in 2026, but it has not stopped. Release 2026c, dated 2026-07-08, carries Alberta’s move to permanent -06 and Morocco’s planned move to permanent +00 on 2026-09-20. A cached offset is a snapshot of a moving target.
Here is the bug, with real output. Two offsets were observed in January and written to the database as numbers. In July, the northern hemisphere has moved forward and the southern hemisphere has moved back, so both stored numbers are wrong — and they are wrong in opposite directions:
// Offsets observed once, in January, and stored as numbers.
const stored = {
'America/New_York': -300, // "UTC-5"
'Australia/Sydney': +660, // "UTC+11"
}
const WINDOW = { start: 9, end: 17 }
const instant = new Date('2026-07-16T22:00:00Z')
const fmt = (zone) =>
new Intl.DateTimeFormat('en-GB', {
timeZone: zone, weekday: 'short', hour: '2-digit',
minute: '2-digit', hourCycle: 'h23',
}).formatToParts(instant)
for (const [zone, offset] of Object.entries(stored)) {
const n = new Date(instant.getTime() + offset * 60_000)
const naiveHour = n.getUTCHours()
const p = fmt(zone)
const at = (t) => p.find((x) => x.type === t).value
const verdict = (h) => (h >= WINDOW.start && h < WINDOW.end ? 'SEND' : 'hold')
console.log(
zone.padEnd(20),
'naive', ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][n.getUTCDay()],
String(naiveHour).padStart(2, '0') + ':' + String(n.getUTCMinutes()).padStart(2, '0'),
verdict(naiveHour).padEnd(5),
'| actual', at('weekday'), at('hour') + ':' + at('minute'),
verdict(Number(at('hour'))),
)
}$ node naive-offset.mjs
America/New_York naive Thu 17:00 hold | actual Thu 18:00 hold
Australia/Sydney naive Fri 09:00 SEND | actual Fri 08:00 holdThe Sydney row is the whole problem in one line. The naive arithmetic believes it is 09:00 on a Friday and releases the send. It is 08:00. The recipient gets a cold email an hour before the window their sender promised to respect. Move the instant to 2026-07-18T04:30:00Z and the same one-hour error crosses midnight in New York instead: the naive clock reads Fri 23:30 while the recipient’s reads Sat 00:30, so a weekdays-only campaign is answering the wrong question about which day it is.
| Representation | Example | Survives DST? | Verdict |
|---|---|---|---|
| IANA identifier | America/New_York | Yes — carries the transition rules | Store this |
| UTC offset | -05:00, -300 minutes | No — correct on one side of each transition | Derive it, never store it |
| Zone abbreviation | CST, IST, BST | No, and ambiguous besides | Never |
On that third row, the tz database’s own theory.html is blunt: "Application writers should note that these abbreviations are ambiguous in practice: e.g., CST means one thing in China and something else in North America." IST alone is claimed by India, Ireland and Israel.
How do you validate a time zone string before you trust it?
A zone identifier arriving from a CSV, an enrichment API or a form field is a string, and most strings that look like zones are not. The runtime already knows the real list. Intl.supportedValuesOf("timeZone") returns it — "duplicates are omitted and the array is sorted in ascending lexicographical order", with "over 400 identifiers in common use". On the runtime below it is exactly 418.
One trap first. Intl.DateTimeFormat accepts more than zone names. Its timeZone option takes "named identifiers such as ‘UTC’, ‘America/New_York’, and ‘Etc/GMT+8’, and offset identifiers such as ‘+01:00’, ‘-2359’, and ‘+23’". So a bare try/catch around the constructor is not a validator: it happily accepts the fixed offset you were trying to reject. Check membership first, and only fall back to the constructor for aliases the engine honours but does not list.
A second trap hides inside the first. Note where MDN files Etc/GMT+8: under named identifiers, not offset identifiers. It contains a slash, the constructor accepts it, and it is nonetheless a fixed offset that will never observe a transition — so a "must contain a slash" rule does not keep it out. Its sign is inverted too. The tz database’s etcetera file explains why: "POSIX has positive signs west of Greenwich, but many people expect positive signs east of Greenwich", so TZ=‘Etc/GMT+4’ "corresponds to 4 hours behind UT". Reject the whole Etc/ prefix by name.
const ZONES = new Set(Intl.supportedValuesOf('timeZone'))
export function isUsableZone(id) {
if (typeof id !== 'string' || !id.includes('/')) return false // rejects "+01:00", "UTC", "EST"
if (id.startsWith('Etc/')) return false // fixed offsets wearing a zone-shaped name
if (ZONES.has(id)) return true
try {
// Accept live aliases the engine honours but does not list, e.g. Asia/Kolkata.
new Intl.DateTimeFormat('en-US', { timeZone: id })
return true
} catch {
return false
}
}
for (const id of [
'America/New_York', 'Asia/Kolkata', 'Asia/Calcutta',
'Europe/Cologne', 'America/Boston', 'Etc/GMT+8', '+01:00', 'UTC', 'EST',
]) console.log(String(id).padEnd(20), isUsableZone(id))$ node validate.mjs
America/New_York true
Asia/Kolkata true
Asia/Calcutta true
Europe/Cologne false
America/Boston false
Etc/GMT+8 false
+01:00 false
UTC false
EST falseWhy not just ask a language model for the zone?
Because the failure mode is invisible. A model asked for the zone of a company in Cologne will not return an error; it will return a well-formed Area/Location string, and there is a real chance the string is Europe/Cologne. That is not a zone. Neither is America/Boston, and neither is Asia/Bangalore. All three parse fine, all three read fine in a code review, and all three throw at send time:
$ node -e 'new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Cologne" })'
RangeError: Invalid time zone specified: Europe/CologneThis is structural, not a knock on any particular model. Zone identifiers are a controlled vocabulary maintained by committee — tzdb’s naming guidance avoids country names "to avoid incompatibilities when countries change their name (e.g., Swaziland→Eswatini)" and prefers "cities or small islands, not countries or regions". No rule generates the right string from a place name, so generation is the wrong tool. A gazetteer join is deterministic and diffable.
If a model is in this path at all — normalising a messy free-text location into a city and country, say — run its output through isUsableZone before the queue sees it, and treat a rejection as "unknown" rather than retrying until something validates.
How do you compute local working hours correctly?
Never by adding an offset. Ask Intl for the local wall-clock fields directly: formatToParts returns "an Array of objects containing the formatted date in parts", each with a type and a value, so you read the hour and weekday without parsing a formatted string. Cache one formatter per zone — constructing an Intl.DateTimeFormat is the expensive part, and a queue evaluates the same handful of zones repeatedly.
const WINDOW = { start: 8, end: 17, weekdaysOnly: true }
const cache = new Map()
function formatterFor(timeZone) {
let f = cache.get(timeZone)
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
hourCycle: 'h23', // state the 0-23 cycle explicitly rather than relying on hour12
})
cache.set(timeZone, f)
}
return f
}
export function localAt(instant, timeZone) {
const parts = formatterFor(timeZone).formatToParts(instant)
const at = (type) => parts.find((p) => p.type === type).value
return { weekday: at('weekday'), hour: Number(at('hour')), minute: Number(at('minute')) }
}
export function inWindow(instant, timeZone) {
const { weekday, hour } = localAt(instant, timeZone)
if (WINDOW.weekdaysOnly && (weekday === 'Sat' || weekday === 'Sun')) return false
return hour >= WINDOW.start && hour < WINDOW.end
}
// Demo driver.
const instant = new Date('2026-07-15T16:00:00Z')
for (const zone of [
'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Europe/Berlin',
'Asia/Calcutta', 'Asia/Tokyo', 'Australia/Sydney',
]) {
const { weekday, hour, minute } = localAt(instant, zone)
console.log(
zone.padEnd(22), weekday,
String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0'),
inWindow(instant, zone) ? 'SEND' : 'hold',
)
}$ node window.mjs
America/Los_Angeles Wed 09:00 SEND
America/New_York Wed 12:00 SEND
Europe/London Wed 17:00 hold
Europe/Berlin Wed 18:00 hold
Asia/Calcutta Wed 21:30 hold
Asia/Tokyo Thu 01:00 hold
Australia/Sydney Thu 02:00 holdThree things fall out of that table. Asia/Calcutta is at :30, because India is UTC+05:30 — code that stores offsets in whole hours is broken before daylight saving enters the picture. Tokyo and Sydney are on the next day, so the weekday check has to come from the same formatted parts as the hour, not from the UTC date. And one global window cannot serve that list, which is the argument for per-recipient scheduling in a single screenshot.
Two notes. Pass hourCycle: "h23" rather than hour12: false, so the 0–23 cycle is stated rather than inferred. And pin the locale — you are parsing the weekday as an ASCII string, and the server’s default locale would eventually hand you 金 or Fr, at which point the Saturday check silently stops matching.
Note what the function does not do. It answers "may I send right now?", not "when is the next valid moment?". A queue that wakes on a short interval and asks the first question needs no reverse mapping from wall time back to an instant — which is where the hard cases live: the hour that occurs twice each autumn and the hour that never happens each spring.
What do you do when the zone cannot be resolved?
Pick a default, write it down, and make it visible on the record — silently assuming the sender’s zone is the failure this article opened with. Reasonable defaults are the zone the campaign owner configured, or the country’s principal-location zone where the country has only one. Both are guesses; a labelled guess can be filtered and counted.
In Decknative, per-lead scheduling sits alongside the daily cap and the campaign send window: a lead whose local time is outside the window is not skipped, it is held until its own window opens. That is a deliberate ordering choice — a queue that drops out-of-window sends quietly ends up delivering only to whichever zones happen to overlap the operator’s afternoon.
The short version
- Store an IANA identifier per lead. Never an offset, never an abbreviation.
- Resolve by lookup: city gazetteer, then coordinates, then country — and from country only where zone1970.tab shows one row.
- Validate against a Set from Intl.supportedValuesOf("timeZone"), with a constructor fallback for unlisted aliases and an explicit reject for offset-shaped strings — the Etc/ prefix included.
- Derive hour and weekday with formatToParts, from one cached formatter per zone, with hourCycle "h23" and a pinned locale.
- Ask "is now inside the window?" on a tick, rather than computing the next valid instant.
- Keep the runtime’s tz data current — a stale ICU means a stale rule.
Common questions
- Is there a best time of day to send cold email?
- Nobody publishing a universal answer has evidence that generalises to your list, and we are not going to add to the pile. What is defensible without any study is narrower: a message delivered while the recipient is asleep is below everything that arrives before they wake, and a message delivered on a Sunday is below everything from Monday morning. Aim for their working hours, then measure your own replies by local send hour.
- Can I just store a UTC offset instead of an IANA zone name?
- No. An offset is correct on one side of each daylight-saving transition and wrong on the other, so a value cached in January misfires from March until November. Offsets also cannot express political changes — tzdb release 2026c carries Alberta’s move to permanent -06 and Morocco’s planned move to permanent +00 on 2026-09-20. Store the identifier and derive the offset at the instant you need it.
- How many countries can be resolved to a time zone from the country alone?
- Most of them. In tzdb 2026c, zone1970.tab has 312 rows covering 247 ISO 3166 country codes, and 33 of those codes appear on more than one row. The unresolvable set includes the US (29 rows), Russia (27), Canada (23), Brazil (16), Australia (13), Mexico (12), Argentina (12) and Kazakhstan (7). Spain and Portugal are on the list too, because of the Canary Islands and the Azores.
- Is Intl.supportedValuesOf a complete list of valid time zones?
- No, and treating it as one will reject working identifiers. It omits "UTC" and everything under Etc/, and it publishes only one member of each alias pair — Node v22.22.1 with ICU 78.2 returns Asia/Calcutta and Europe/Kiev, while MDN describes the standardised behaviour as returning the primary identifiers Asia/Kolkata and Europe/Kyiv. All four work when passed to Intl.DateTimeFormat. Use the list as a fast path and fall back to the constructor.
- Why not use a try/catch around Intl.DateTimeFormat as the validator?
- Because it accepts more than you want. MDN documents that the timeZone option takes offset identifiers such as "+01:00", "-2359" and "+23" alongside named zones, so a plain try/catch passes exactly the fixed-offset values you are trying to keep out of the database. It also accepts Etc/GMT+8, which has a slash but is still a fixed offset — and a sign-inverted one, since tzdb follows POSIX in putting positive signs west of Greenwich. Reject anything without a "/" and anything under Etc/ first, then check the supported set, then fall back to the constructor.
- Should I ask an LLM for a lead’s time zone?
- Prefer a lookup table. A model returns a well-formed Area/Location string whether or not that string exists — Europe/Cologne, America/Boston and Asia/Bangalore all read as plausible and all throw RangeError from Intl.DateTimeFormat. Zone identifiers are a controlled vocabulary with no generative rule behind them, so a join against GeoNames or zone1970.tab is deterministic where generation is not. If a model is in the path, validate its output before it reaches the queue.
- Does the law restrict what hours I can send email?
- This is not legal advice, and you should ask a lawyer about your jurisdiction and your list. Broadly, the hour-of-day rules that exist in US federal law target telephone calls rather than email: 16 CFR 310.4(c) restricts telemarketing calls to a residence, absent that person’s prior consent, to "between 8:00 a.m. and 9:00 p.m. local time at the called person’s location". Email regimes are different in kind: CAN-SPAM (15 U.S.C. 7704) governs header accuracy, identification as an advertisement, a working opt-out and a valid physical postal address, and regulation 22 of the UK’s PECR requires prior consent for unsolicited direct-marketing email to individual subscribers. Neither sets an hour-of-day limit.
Sources
- IANA — Time Zone Database
- tzdb — NEWS (release 2026c: Alberta, Morocco)
- tzdb — zone1970.tab (country ↔ zone table)
- tzdb — theory.html (zone naming guidelines and abbreviations)
- tzdb — etcetera (why Etc/GMT+N is N hours behind UT)
- RFC 6557 — Procedures for Maintaining the Time Zone Database
- RFC 5322 §3.3 — Date and Time Specification
- MDN — Intl.supportedValuesOf()
- MDN — Intl.DateTimeFormat() constructor (timeZone option)
- MDN — Intl.DateTimeFormat.prototype.formatToParts()
- MDN — Temporal.ZonedDateTime: time zones and offsets
- Unicode CLDR — windowsZones.xml (Windows ↔ IANA mapping)
- GeoNames — export dumps (cities1000 / cities15000, CC BY 4.0)
- timezone-boundary-builder — time zone polygons (ODbL)
- 16 CFR 310.4 — Abusive telemarketing acts or practices (govinfo)
- 15 U.S.C. 7704 — CAN-SPAM Act requirements (govinfo)
- PECR 2003 reg. 22 — unsolicited electronic mail (legislation.gov.uk)