Nakodo emails creators and local businesses on a brand's behalf, so the question "when does this go out" is a product decision, not a scheduling detail. An email that arrives at 03:00 sits under everything that came in before breakfast. One that arrives mid-morning on a weekday is read.
So every outbound message gets a send time computed from where the recipient is, and the scheduler only ever hands the sender work whose time has come.
The first version of that had a table like this:
const COUNTRY_TIMEZONES: Record<string, string> = {
US: "America/Chicago",
CA: "America/Toronto",
AU: "Australia/Sydney",
// ...60 more
};
Sixty-odd countries, one IANA zone each, with a comment saying that countries spanning several zones "pick the one most people live in, or the middle one". The window was { startHour: 9, endHour: 17 }, and inSendWindow asked whether it was a weekday between those hours in that one zone.
That table is wrong in a way that is easy to miss, because the code it feeds is correct. America/Chicago is a real zone, the formatter handles it, the tests pass. The mistake is upstream of all of that: the United States is not a time zone, and picking a middle one does not average the error, it guarantees it. 09:00 in Chicago is 07:00 in Los Angeles. We had built a careful system for not emailing people at bad times, and then told it that a Californian's working day starts at 07:00.
One country, several clocks
The fix is to stop pretending a country is a point:
const COUNTRY_TIMEZONES: Record<string, string | string[]> = {
US: ["America/New_York", "America/Los_Angeles"],
CA: ["America/Toronto", "America/Vancouver"],
MX: ["America/Mexico_City", "America/Tijuana"],
BR: ["America/Sao_Paulo", "America/Manaus"],
AU: ["Australia/Sydney", "Australia/Perth"],
// single-zone countries stay strings
};
export function timeZonesFor(country: string | null | undefined): string[] {
const tz = country ? COUNTRY_TIMEZONES[country.toUpperCase()] : undefined;
return tz ? [tz].flat() : [UNKNOWN_TIMEZONE];
}
A country that spans several zones lists the ones at its ends. [tz].flat() means every caller gets an array and nothing downstream has to care which kind of entry it came from.
Then the window becomes a window in all of them:
// Minutes after local midnight: 9:00 up to 19:30.
export const WINDOW = { start: 9 * 60, end: 19 * 60 + 30 } as const;
// A weekday between 9:00 and 19:30 in every one of the zones.
export function inSendWindow(date: Date, timeZones: readonly string[]): boolean {
return timeZones.every((tz) => {
const t = localTime(date, tz);
const minutes = t.hour * 60 + t.minute;
return t.weekday >= 1 && t.weekday <= 5 && minutes >= WINDOW.start && minutes < WINDOW.end;
});
}
every rather than some is the whole design. Nobody in the country is emailed outside their own working hours, and the price is that the country's usable day is the intersection, not the union.
What the intersection actually costs
This is worth measuring rather than guessing, and you can measure it in Node with no dependencies. Paste this into a file and run it:
const WINDOW = { start: 9 * 60, end: 19 * 60 + 30 };
const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const local = (date, timeZone) => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23",
}).formatToParts(date);
const get = (t) => parts.find((p) => p.type === t)?.value ?? "";
return { weekday: WEEKDAYS[get("weekday")], minutes: Number(get("hour")) * 60 + Number(get("minute")) };
};
const open = (date, zones) => zones.every((tz) => {
const t = local(date, tz);
return t.weekday >= 1 && t.weekday <= 5 && t.minutes >= WINDOW.start && t.minutes < WINDOW.end;
});
function hoursOpen(zones, dayStartUtc) {
let count = 0;
for (let m = 0; m < 24 * 60; m += 15) {
if (open(new Date(dayStartUtc + m * 60_000), zones)) count += 15;
}
return count / 60;
}
const tuesday = Date.UTC(2026, 9, 6); // Tuesday 6 October 2026
console.log(hoursOpen(["America/New_York"], tuesday));
console.log(hoursOpen(["America/New_York", "America/Los_Angeles"], tuesday));
console.log(hoursOpen(["Australia/Sydney", "Australia/Perth"], tuesday));
console.log(hoursOpen(["America/Sao_Paulo", "America/Manaus"], tuesday));
Run on 6 October 2026, that prints 10.5, 7.5, 7.5 and 9.5. One zone gets the full 09:00 to 19:30. The US pair gets 7.5 hours, which is 12:00 to 19:30 in New York and 09:00 to 16:30 in Los Angeles. Brazil, whose ends are only one hour apart, barely notices.
The interesting one is Australia in a different month. Change the date to Date.UTC(2026, 6, 7), a Tuesday in July, and Sydney plus Perth becomes 8.5 instead of 7.5. Sydney observes daylight saving and Perth does not, so the gap between the ends of the country is two hours in July and three in October. The sending capacity of a whole country breathes with the southern hemisphere's clocks, and no code of ours knows that: Intl does.
That is the argument for keeping zone names rather than offsets anywhere near this problem. We store IANA identifiers, ask Intl.DateTimeFormat for the weekday, hour and minute in each, and never write a single offset, DST rule or leap-related special case. The platform's own copy of the tz database is doing the work.
Three details that only show up in the edges
Jitter must not escape the window. Sends get a small random delay so a batch does not land on the same minute. When the window was 09:00 to 17:00 and jitter was up to 20 minutes, a message cleared at 16:55 could be queued for 17:15, which is outside the hours the function just approved:
if (inSendWindow(from, timeZones)) {
const later = new Date(from.getTime() + Math.floor(random() * 20) * 60_000);
return inSendWindow(later, timeZones) ? later : from;
}
If the jitter would push the message out of the window, it goes now. A random offset added to a bounded interval has to be re-checked against the bound, every time.
The formatter gets cached. Finding the next open moment steps forward in quarter hours, snapped to the quarter so the opening is hit exactly, with an eight-day bound so a bad zone cannot loop for ever. That is up to 768 steps, now multiplied by the number of zones, and new Intl.DateTimeFormat(...) is not cheap:
const formats = new Map<string, Intl.DateTimeFormat>();
export function localTime(date: Date, timeZone: string) {
let f = formats.get(timeZone);
if (!f) {
f = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
formats.set(timeZone, f);
}
const parts = f.formatToParts(date);
// ...
}
Sixty-odd countries means at most about seventy formatters for the lifetime of the process, keyed by zone name. A Map at module scope is the entire cache.
The default changed from a guess to a fact. The old code had DEFAULT_TIMEZONE = "America/New_York" for recipients whose country we do not know. That is a guess dressed as a default: it is right for some of them and silently wrong for everyone else. It is now:
export const UNKNOWN_TIMEZONE = "UTC";
When we do not know where someone is, mail goes out on the sender's own clock, Nakodo being a UK company. It is not better for the recipient, but it is honest about what we know, and it is the same answer every time.
Why not the recipient's own zone
Because we do not have it. A YouTube channel exposes a country, not a city. An Instagram or TikTok account often exposes neither. A business listing has an address, which is the one case where we could do better, and a country-level answer is already correct for most of them: India, China and Japan are each a single zone.
The honest version of this feature is "the country we know, resolved to the hours that are reasonable everywhere in it". Guessing a city to get back the three hours we gave up would be inventing data to improve a number on a graph.
If you want to see the other end of the same machinery, the follow-up rules it feeds are written up in plain English on how to follow up with a YouTuber, and the daily sending allowances the scheduler spreads across the window are on the pricing page.
The general shape, though, transfers to anything that acts on a user's behalf at a particular hour: find the places in your table where a region has been collapsed to a point, and decide explicitly whether the overlap or the union is the one that cannot hurt anybody.
Top comments (0)