To count business days between two dates, step through each day and skip the ones that are weekends or public holidays in the country you care about. The loop is ten lines. What makes it hard is getting the inputs right: which days are the weekend, which holidays count, which year’s list a holiday is in, and which time zone your dates are in. We hit all five of the bugs below while writing this, one of them in our own API.
The code
Holidays come from our World Holidays API, one request per country per year, cached.
// business-days.js: business days between two dates, with real public holidays (Node 18+)
const KEY = process.env.APIVERVE_API_KEY;
// Day numbers: 0 = Sunday ... 6 = Saturday. Most countries rest Saturday and Sunday.
const WEEKENDS = { SA: [5, 6], EG: [5, 6], BH: [5, 6], IL: [5, 6], IR: [5] };
const weekendOf = (country) => WEEKENDS[country] || [6, 0];
// Dates stay as 'YYYY-MM-DD' strings and all arithmetic is in UTC, so the
// server's time zone and daylight saving can't move a date by a day.
const toDate = (s) => new Date(`${s}T00:00:00Z`);
const toStr = (d) => d.toISOString().slice(0, 10);
const addDays = (s, n) => { const d = toDate(s); d.setUTCDate(d.getUTCDate() + n); return toStr(d); };
const cache = new Map(); // 'US:2027' -> Set of holiday dates
async function holidays(country, year) {
const key = `${country}:${year}`;
if (!cache.has(key)) {
const res = await fetch(`https://api.apiverve.com/v1/worldholidays?country=${country}&year=${year}`, {
headers: { 'x-api-key': KEY },
signal: AbortSignal.timeout(5000),
});
const body = await res.json();
if (!res.ok || body.status !== 'ok') throw new Error(`holidays ${country} ${year}: ${JSON.stringify(body.error)}`);
// On the free plan the year parameter is ignored and you get the current year. Check.
if (body.data.year !== year) throw new Error(`asked for ${year} holidays, got ${body.data.year}`);
// 'public' only: the lists also carry observances like Valentine's Day.
cache.set(key, new Set(body.data.holidays.filter((h) => h.type === 'public').map((h) => h.date.slice(0, 10))));
}
return cache.get(key);
}
async function isBusinessDay(day, country) {
if (weekendOf(country).includes(toDate(day).getUTCDay())) return false;
return !(await holidays(country, Number(day.slice(0, 4)))).has(day);
}
// Counts both ends, like Excel's NETWORKDAYS: Monday to Friday of one week is 5.
async function businessDaysBetween(start, end, country) {
let count = 0;
for (let d = start; d <= end; d = addDays(d, 1)) if (await isBusinessDay(d, country)) count++;
return count;
}
// The date n business days after start (start itself doesn't count).
async function addBusinessDays(start, n, country) {
let d = start;
while (n > 0) { d = addDays(d, 1); if (await isBusinessDay(d, country)) n--; }
return d;
}
module.exports = { businessDaysBetween, addBusinessDays, isBusinessDay };
With a paid key:
US, Dec 21 2026 to Jan 8 2027: 13
US, 3 business days after Dec 28 2027: 2028-01-03
US, November 2026: 19
SA, November 2026: 22
GB, Easter week 2026 (Mar 30 to Apr 10): 8
We checked each one by hand. Three weeks around Christmas would be 15 weekdays; Christmas and New Year’s Day both fall on a Friday, which leaves 13. Easter week in Britain loses Good Friday and Easter Monday. The other three are the bugs.
Bug 1: your server’s time zone moves the date
This is the classic, and it’s easy to ship:
$ TZ=America/Chicago node -e "console.log(new Date('2026-11-01').getDay(), new Date('2026-11-01').toDateString())"
6 Sat Oct 31 2026
$ TZ=UTC node -e "console.log(new Date('2026-11-01').getDay())"
0
JavaScript parses a date-only string as midnight UTC. getDay() then reports the day in the machine’s local time zone, and in Chicago midnight UTC on November 1 is still October 31. The same code says Sunday on a UTC server and Saturday on a laptop in the US, so the bug shows up only on some machines.
The code above never lets a date leave UTC: strings in, T00:00:00Z appended, getUTCDay() and setUTCDate() only. If your dates arrive as timestamps, convert them to the calendar date in the time zone that matters to the business (usually the customer’s or the office’s) before you count anything.
Bug 2: a holiday can sit in the wrong year
When a US federal holiday falls on a Saturday, it’s observed on the Friday before. New Year’s Day 2028 is a Saturday, so it’s observed on Friday, December 31, 2027. Our holiday data puts that day in the 2027 list as “New Year’s Day (substitute day)”, which is correct, but it means a calculation near the end of December needs both years’ lists.
That’s the second line of output. Counting three business days from Tuesday, December 28, 2027 gives Wednesday the 29th, Thursday the 30th, then skips the holiday on Friday the 31st and the weekend, and lands on Monday, January 3, 2028. The code fetches whichever year each day belongs to, so this works without special handling. A version that fetches one year’s list for “the year of the start date” gets it wrong.
The same rule produced Friday, July 3, 2026 as a holiday, because July 4 was a Saturday.
Bug 3: not every country’s weekend is Saturday and Sunday
Saudi Arabia, Egypt, Bahrain and Israel take Friday and Saturday off. Iran’s weekend is Friday. The UAE moved from Friday and Saturday to Saturday and Sunday in 2022. A business-day function with Saturday and Sunday hard-coded gives wrong answers for all of those, by about one day a week.
Ours had exactly that bug. Our Working Days API, which counts business days per month, treated Saturday and Sunday as the weekend for every country. For Saudi Arabia in November 2026 it returned 21 working days; with the real Friday and Saturday weekend, as above, the answer is 22. We found the same for Egypt, Bahrain, Iran and Israel while writing this post. Until that’s fixed, the WEEKENDS map in the code is the workaround. It covers the countries we checked; if you serve others, look up their weekend rather than assuming.
Bug 4: “public holiday” is a list, and you have to choose
A country’s holiday list is longer than its days off. Germany’s 2026 list from our API has 30 entries: 9 public holidays, 2 bank holidays and 19 observances, such as Valentine’s Day, Mother’s Day and the Carnival days. The US list includes Tax Day and Father’s Day. Counting every entry as a day off is a common bug, which is why the code keeps only type: 'public'.
Even “public” needs a decision. The US list includes Columbus Day and Veterans Day. They’re federal holidays: banks and the post office close, most shops and many companies don’t. For a delivery estimate you probably want your carrier’s calendar; for settlement dates you want the bank’s. And national lists leave out regional holidays: Germany’s states each add their own, and India’s national list has 6 days where most states observe many more. If your business days depend on a region, you’ll need that region’s list on top.
Bug 5: an ignored parameter that looks like an answer
We ran the same demo on a free key, and the first line failed:
error: asked for 2027 holidays, got 2026
On our free plan, the year parameter is ignored and the API returns the current year’s holidays, with "ignored_params": ["year"] in the response’s premium block to say so. A function that doesn’t check would happily apply 2026’s dates to 2027 and miscount every holiday that moves, such as Thanksgiving, Memorial Day and Easter. That’s why holidays() compares the year it got with the year it asked for and throws. It’s a one-line check worth having for any API that might fall back to a default without failing.
Which end counts
Decide whether the first and last days count, and write it down. businessDaysBetween counts both ends, like Excel’s NETWORKDAYS, so a Monday-to-Friday week is 5. addBusinessDays doesn’t count the start day, which matches how people use “within 3 business days”: an order placed on Monday is due Thursday. Both conventions are common, and mixing them across two parts of the same app is how a deadline ends up a day early.
Stepping one day at a time is fast enough for any realistic range: ten years is about 3,650 iterations, and the holiday lookups are cached per country and year. If you call it thousands of times a second, precompute the business days for each year once and count with a binary search.