Add business days in JavaScript, excluding holidays
Build a delivery-date estimator that skips weekends, observed public holidays, and your own closures, with a day-by-day explanation of the result.
On this page
Working example
HTML + JavaScriptBefore you start
Build a small delivery-date estimator: choose a start date, add a number of business days, and inspect every counted or skipped date. The same calculation can support a dispatch estimate or an internal due date when it matches your working calendar.
This example uses Monday through Friday, excludes the start date, and adds 1 to 60 business days. Choose US federal holidays or a UK bank-holiday calendar. Add your own closed dates when your business needs them. The Holiday API supplies published dates; the JavaScript defines which days your application treats as working days.
Create a Public key on the keys page. Allow localhost and your site's hostname. Replace YOUR_PUBLIC_API_KEY in the downloaded HTML, then serve it from its folder:
python3 -m http.server 8000Open the local example. Use the allowed hostname instead of opening the file directly. Public keys belong in browser examples; keep secret keys on your server.
1. Make the working-day policy explicit
A business day is an application rule. This form makes its starting assumptions visible instead of silently treating every public holiday as a universal delivery closure.
<form id="business-form" aria-busy="false">
<div class="fields">
<label>Start date<input id="start" type="date" required min="2000-01-01" max="2099-12-31" value="2026-12-23"></label>
<label>Business days to add<input id="days" type="number" min="1" max="60" step="1" value="3" required></label>
<label class="wide">Holiday calendar<select id="calendar">
<option value="us">US federal holidays</option>
<option value="eng">England and Wales bank holidays</option>
<option value="sct">Scotland bank holidays</option>
<option value="nir">Northern Ireland bank holidays</option>
</select></label>
<label class="wide">Extra closed dates (optional)<textarea id="closures" rows="2" placeholder="2026-12-24 2026-12-31" aria-describedby="policy"></textarea></label>
</div>
<p id="policy" class="note">Monday–Friday. Start date excluded. Enter extra closures as YYYY-MM-DD, one per line. These calendars are a starting policy, not a carrier delivery promise.</p>
<button type="submit">Calculate date</button>
<p id="status" role="status" aria-live="polite" aria-atomic="true" data-error="false">No request until you calculate.</p>
</form>The US option excludes observed federal holidays. The UK options select England and Wales, Scotland, or Northern Ireland. Carrier schedules, local holidays, cutoff times, Saturday deliveries, and company-specific working weekends can differ. For this example, extra closed dates can remove working days; they cannot turn a weekend into a working day.
Start from the date your application has already chosen for processing. For example, if an order arrives after your dispatch cutoff, apply that rule before this calculation. This tutorial produces an estimate under the selected policy, not a carrier delivery promise.
2. Load the calendar for each year you reach
Fetch /holiday/{country}?year={year} and check the response before calculating. A December start can require a January calendar too. The example loads each needed year once per calculation, as it reaches that year.
async function loadCalendar(calendar, year, signal) {
if (!API_KEY.startsWith('parse_public_')) throw new Error('Add your public API key and allow this hostname.');
const url = new URL(`https://api.parseapi.com/holiday/${calendar.country}`);
url.search = new URLSearchParams({ year: String(year), key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const reason = response.status === 404 ? `The ${year} calendar is unavailable.`
: response.status === 401 || response.status === 403 ? 'Check your public key and allowed hostnames.'
: response.status === 429 ? 'The request limit was reached.' : 'The holiday request failed.';
throw new Error(`${reason} No estimate was calculated.`);
}
return readCalendar(await response.json(), calendar, year);
}
function readCalendar(data, calendar, year) {
const fail = () => new Error(`Could not verify the ${year} calendar. No estimate was calculated.`);
if (!data || data.country !== calendar.country || data.year !== year || !Array.isArray(data.holidays) || !data.holidays.length) throw fail();
const holidays = new Map();
for (const row of data.holidays) {
if (!row || typeof row.name !== 'string' || !row.name.trim() || typeof row.date !== 'string'
|| !['public', 'observance'].includes(row.type) || typeof row.substitute !== 'boolean'
|| !(row.regions === null || Array.isArray(row.regions) && row.regions.length > 0 && row.regions.every(region => typeof region === 'string' && /^[A-Z0-9-]+$/.test(region)))) throw fail();
try { dateDay(row.date); } catch { throw fail(); }
if (Number(row.date.slice(0, 4)) !== year) throw fail();
if (row.type !== 'public' || row.regions !== null && !row.regions.includes(calendar.region)) continue;
const label = row.name + (row.substitute ? ' (substitute day)' : '');
holidays.set(row.date, [...new Set([...(holidays.get(row.date) || []), label])]);
}
if (!holidays.size) throw fail();
return holidays;
}Keep rows whose type is public. Cultural observances do not automatically close a business. For a regional calendar, include a row when regions is null or contains the chosen subdivision code. UK codes are ENG, WLS, SCT, and NIR; England and Wales share the calendar used here.
Use the returned dates as published. A row with substitute: true already names the substitute date. Do not shift it to another Monday. Store dates in a map so two holiday names on one date still exclude only one day.
If any needed year is unavailable, the calculation stops. An error or missing calendar must not quietly become a year with no holidays. Coverage varies by calendar and year; see the Holiday reference before offering another country.
3. Add whole dates, not local clock hours
The calculation treats each YYYY-MM-DD value as an integer day at UTC midnight. UTC is an arithmetic convention here; the input still represents a date in the selected business calendar. No browser time zone is used to decide the weekday.
async function addBusinessDays(start, count, calendar, closures, lookup) {
let day = dateDay(start);
if (!Number.isInteger(count) || count < 1 || count > 60) throw new Error('Add between 1 and 60 whole business days.');
const calendars = new Map();
const audit = [];
let counted = 0;
while (counted < count) {
day += 1; // The start date is day zero, even if it is a working day.
if (audit.length >= 366) throw new Error('No date found within a year. Review your closure policy.');
const date = new Date(day * DAY);
const iso = date.toISOString().slice(0, 10);
dateDay(iso);
const year = date.getUTCFullYear();
if (!calendars.has(year)) calendars.set(year, await lookup(calendar, year));
const names = calendars.get(year).get(iso) || [];
const reasons = [];
if ([0, 6].includes(date.getUTCDay())) reasons.push('Weekend');
reasons.push(...names);
if (closures.has(iso)) reasons.push('Your extra closure');
if (!reasons.length) counted += 1;
audit.push({ date: iso, counted: !reasons.length, reason: reasons.length ? reasons.join(' · ') : `Business day ${counted}` });
}
return { date: audit[audit.length - 1].date, audit, years: [...calendars.keys()] };
}Advance one date at a time. A date counts only when it is Monday through Friday, is absent from the selected holiday map, and is absent from your extra closures. Stop when the requested number of working days has been counted.
This avoids daylight-saving changes turning a supposed 24-hour step into the wrong local date. It also keeps the start boundary explicit: the start date is day zero. Even a Friday start does not count as the first business day.
4. Show why the answer changed
The result includes the estimated date and a day-by-day explanation. Each skipped date names its reason, including substitute holidays and your extra closures. Response names are rendered as text.
function showResult(result, count) {
document.querySelector('#due-date').textContent = result.date;
document.querySelector('#summary').textContent = `${count} business days across ${result.audit.length} calendar days. Start date excluded. Calendars checked: ${result.years.join(', ')}.`;
document.querySelector('#audit').replaceChildren(...result.audit.map(day => {
const row = document.createElement('tr');
for (const text of [day.date, day.reason]) {
const cell = document.createElement('td');
cell.textContent = text;
row.append(cell);
}
return row;
}));
answer.hidden = false;
setStatus('Estimate ready. Expand the day list to check the calculation.');
}The example sends no requests on page load. Editing any input cancels the current calculation and hides the old estimate. A revision check prevents a late response from displaying an answer for inputs the user has already changed. A rejected key, rate limit, malformed response, or timeout leaves the form available for another attempt.
Try the boundary cases
These fixtures make the policy easy to check:
- US federal: start December 23, 2026, add three business days with no extra closures. Expected date: 2026-12-29.
- England and Wales: use the same start and duration with no extra closures. Expected date: 2026-12-30.
- Extra closure: use the US federal calendar, start December 23, and add three business days with December 24 closed. Expected date: 2026-12-30.
- Year boundary: use the US federal calendar, start December 30, 2026, and add three business days with no extra closures. Expected date: 2027-01-05.
Expand the day list to see Christmas, the weekend, and the UK Boxing Day substitute. Change a date during a request and confirm the old answer stays hidden. A request for an unavailable year should show an error instead of an optimistic estimate.
Put the estimate in your application
Keep the date, selected calendar, start-date rule, and extra closures with the result so another person can reproduce it. If the date controls a fulfillment commitment, repeat the calculation with your trusted server-side policy; a browser form can be edited.
For counting working days between two existing dates, use the business days calculator. To collect the destination before applying a delivery policy, build a country and state dropdown or autofill a US city and state from a ZIP.
Complete example
One HTML file with the form, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.
View and copy the complete source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Add business days, skipping weekends and holidays</title>
<style>
:root { color-scheme: light dark; --bg: #fff; --ink: #152029; --muted: #5e6b75; --line: #d9e2e7; --field: #f6f9fa; --accent: #007c91; --button: #c5f6ff; --error: #b42335; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; } }
:root[data-theme="dark"] { color-scheme: dark; --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 560px; margin: 0 auto; }
h1 { margin: 0 0 6px; font-size: clamp(23px, 5vw, 30px); font-weight: 600; letter-spacing: -.035em; }
p { margin: 0; }
.intro, .note { color: var(--muted); }
form { margin-top: 24px; }
.fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
.wide { grid-column: 1 / -1; }
label { display: block; font-size: 13px; font-weight: 550; }
input, select, textarea, button { font: inherit; }
input, select, textarea { width: 100%; min-width: 0; margin-top: 7px; min-height: 46px; padding: 11px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
textarea { resize: vertical; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
button { min-height: 46px; margin-top: 14px; padding: 11px 18px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
button:disabled { opacity: .55; cursor: wait; }
#status { min-height: 44px; margin-top: 14px; color: var(--muted); font-size: 13px; }
#status[data-error="true"] { color: var(--error); }
.note { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 16px; font-size: 12px; }
#answer { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 20px; }
#due-date { display: block; font-size: clamp(28px, 7vw, 40px); color: var(--accent); letter-spacing: -.035em; }
#summary { margin: 8px 0 16px; color: var(--muted); font-size: 13px; }
summary { min-height: 44px; padding: 10px 0; cursor: pointer; font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 10px 6px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
th:first-child, td:first-child { width: 100px; }
@media (max-width: 380px) { body { padding: 18px; } .fields { grid-template-columns: minmax(0, 1fr); } button { width: 100%; } }
</style>
</head>
<body>
<main>
<h1>Count the days you work.</h1>
<p class="intro">Estimate a due date with weekends, a chosen holiday calendar, and your own closures excluded.</p>
<!-- example:form -->
<form id="business-form" aria-busy="false">
<div class="fields">
<label>Start date<input id="start" type="date" required min="2000-01-01" max="2099-12-31" value="2026-12-23"></label>
<label>Business days to add<input id="days" type="number" min="1" max="60" step="1" value="3" required></label>
<label class="wide">Holiday calendar<select id="calendar">
<option value="us">US federal holidays</option>
<option value="eng">England and Wales bank holidays</option>
<option value="sct">Scotland bank holidays</option>
<option value="nir">Northern Ireland bank holidays</option>
</select></label>
<label class="wide">Extra closed dates (optional)<textarea id="closures" rows="2" placeholder="2026-12-24 2026-12-31" aria-describedby="policy"></textarea></label>
</div>
<p id="policy" class="note">Monday–Friday. Start date excluded. Enter extra closures as YYYY-MM-DD, one per line. These calendars are a starting policy, not a carrier delivery promise.</p>
<button type="submit">Calculate date</button>
<p id="status" role="status" aria-live="polite" aria-atomic="true" data-error="false">No request until you calculate.</p>
</form>
<!-- /example:form -->
<section id="answer" hidden aria-label="Calculated date">
<p>Estimated date</p>
<output id="due-date"></output>
<p id="summary"></p>
<details><summary>See every counted and skipped day</summary>
<table><thead><tr><th scope="col">Date</th><th scope="col">What happened</th></tr></thead><tbody id="audit"></tbody></table>
</details>
</section>
</main>
<script>
const API_KEY = 'YOUR_PUBLIC_API_KEY';
const DAY = 86400000;
const CALENDARS = {
us: { country: 'US', region: null },
eng: { country: 'GB', region: 'ENG' },
sct: { country: 'GB', region: 'SCT' },
nir: { country: 'GB', region: 'NIR' }
};
function dateDay(value) {
if (typeof value !== 'string' || !/^20\d{2}-\d{2}-\d{2}$/.test(value)) throw new Error('Use a valid date between 2000 and 2099.');
const ms = Date.parse(`${value}T00:00:00Z`);
if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== value) throw new Error('Use a valid calendar date.');
return ms / DAY;
}
function closedDates(value) {
const dates = value.trim() ? value.trim().split(/\s+/) : [];
if (dates.length > 60) throw new Error('Enter at most 60 extra closed dates.');
dates.forEach(dateDay);
return new Set(dates);
}
// example:request
async function loadCalendar(calendar, year, signal) {
if (!API_KEY.startsWith('parse_public_')) throw new Error('Add your public API key and allow this hostname.');
const url = new URL(`https://api.parseapi.com/holiday/${calendar.country}`);
url.search = new URLSearchParams({ year: String(year), key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const reason = response.status === 404 ? `The ${year} calendar is unavailable.`
: response.status === 401 || response.status === 403 ? 'Check your public key and allowed hostnames.'
: response.status === 429 ? 'The request limit was reached.' : 'The holiday request failed.';
throw new Error(`${reason} No estimate was calculated.`);
}
return readCalendar(await response.json(), calendar, year);
}
function readCalendar(data, calendar, year) {
const fail = () => new Error(`Could not verify the ${year} calendar. No estimate was calculated.`);
if (!data || data.country !== calendar.country || data.year !== year || !Array.isArray(data.holidays) || !data.holidays.length) throw fail();
const holidays = new Map();
for (const row of data.holidays) {
if (!row || typeof row.name !== 'string' || !row.name.trim() || typeof row.date !== 'string'
|| !['public', 'observance'].includes(row.type) || typeof row.substitute !== 'boolean'
|| !(row.regions === null || Array.isArray(row.regions) && row.regions.length > 0 && row.regions.every(region => typeof region === 'string' && /^[A-Z0-9-]+$/.test(region)))) throw fail();
try { dateDay(row.date); } catch { throw fail(); }
if (Number(row.date.slice(0, 4)) !== year) throw fail();
if (row.type !== 'public' || row.regions !== null && !row.regions.includes(calendar.region)) continue;
const label = row.name + (row.substitute ? ' (substitute day)' : '');
holidays.set(row.date, [...new Set([...(holidays.get(row.date) || []), label])]);
}
if (!holidays.size) throw fail();
return holidays;
}
// /example:request
// example:calculate
async function addBusinessDays(start, count, calendar, closures, lookup) {
let day = dateDay(start);
if (!Number.isInteger(count) || count < 1 || count > 60) throw new Error('Add between 1 and 60 whole business days.');
const calendars = new Map();
const audit = [];
let counted = 0;
while (counted < count) {
day += 1; // The start date is day zero, even if it is a working day.
if (audit.length >= 366) throw new Error('No date found within a year. Review your closure policy.');
const date = new Date(day * DAY);
const iso = date.toISOString().slice(0, 10);
dateDay(iso);
const year = date.getUTCFullYear();
if (!calendars.has(year)) calendars.set(year, await lookup(calendar, year));
const names = calendars.get(year).get(iso) || [];
const reasons = [];
if ([0, 6].includes(date.getUTCDay())) reasons.push('Weekend');
reasons.push(...names);
if (closures.has(iso)) reasons.push('Your extra closure');
if (!reasons.length) counted += 1;
audit.push({ date: iso, counted: !reasons.length, reason: reasons.length ? reasons.join(' · ') : `Business day ${counted}` });
}
return { date: audit[audit.length - 1].date, audit, years: [...calendars.keys()] };
}
// /example:calculate
const form = document.querySelector('#business-form');
const button = form.querySelector('button');
const status = document.querySelector('#status');
const answer = document.querySelector('#answer');
let revision = 0;
let controller;
function setStatus(message, error = false) { status.textContent = message; status.dataset.error = String(error); }
function cancel() {
revision += 1;
controller?.abort();
controller = undefined;
answer.hidden = true;
button.disabled = false;
form.setAttribute('aria-busy', 'false');
}
// example:result
function showResult(result, count) {
document.querySelector('#due-date').textContent = result.date;
document.querySelector('#summary').textContent = `${count} business days across ${result.audit.length} calendar days. Start date excluded. Calendars checked: ${result.years.join(', ')}.`;
document.querySelector('#audit').replaceChildren(...result.audit.map(day => {
const row = document.createElement('tr');
for (const text of [day.date, day.reason]) {
const cell = document.createElement('td');
cell.textContent = text;
row.append(cell);
}
return row;
}));
answer.hidden = false;
setStatus('Estimate ready. Expand the day list to check the calculation.');
}
// /example:result
form.addEventListener('input', () => { cancel(); setStatus('Inputs changed. Calculate again for a new estimate.'); });
form.addEventListener('submit', async event => {
event.preventDefault();
cancel();
const current = revision;
const request = new AbortController();
controller = request;
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
try {
const start = document.querySelector('#start').value;
const count = Number(document.querySelector('#days').value);
const calendar = CALENDARS[document.querySelector('#calendar').value];
if (!calendar) throw new Error('Choose a supported holiday calendar.');
const closures = closedDates(document.querySelector('#closures').value);
dateDay(start);
button.disabled = true;
form.setAttribute('aria-busy', 'true');
setStatus('Checking holiday calendars...');
const result = await addBusinessDays(start, count, calendar, closures, (selection, year) => loadCalendar(selection, year, request.signal));
if (current === revision) showResult(result, count);
} catch (error) {
if (current !== revision) return;
setStatus(timedOut ? 'The request timed out. Try again; no estimate was calculated.' : error instanceof Error ? error.message : 'Could not calculate an estimate. Try again.', true);
} finally {
clearTimeout(timeout);
if (current === revision) { button.disabled = false; form.setAttribute('aria-busy', 'false'); controller = undefined; }
}
});
</script>
</body>
</html>
Need a key? Create a public API key. For every field and parameter, see Holiday API reference.