Blog / Coding tips

Count Working Days in JavaScript With UK Bank Holidays

Cover image for Count Working Days in JavaScript With UK Bank Holidays

To count working days between two dates in JavaScript, loop from the start date to the end date one day at a time, skip Saturdays and Sundays with getUTCDay(), and skip any date that's in a list of bank holidays. Do the date maths in UTC on "YYYY-MM-DD" strings, not in local time, or the clocks changing will shift your dates. For the UK, the holiday list comes free from the government: https://www.gov.uk/bank-holidays.json has every bank holiday for England and Wales, Scotland and Northern Ireland, including substitute days.

I used this to build a UK working days calculator (source on GitHub). It counts working days between two dates, adds working days to a date, and has bank holiday pages for 2026 to 2028. This post shows the core code, the bug that makes most "add days" snippets wrong in the UK, and how I load the gov.uk data at build time.

The bug: adding 24 hours isn't adding a day

Most snippets add a day with date.getTime() + 86400000. In the UK that breaks twice a year. On Sunday 25 October 2026 the clocks go back at 2 AM, so that Sunday is 25 hours long. Here's what happens when you add two "days" to Saturday midnight with the computer set to UK time:

// Run with TZ=Europe/London. The clocks go back at 2 AM on Sunday 25 October 2026.
const sat = new Date(2026, 9, 24); // local midnight, Saturday 24 October
const plus2 = new Date(sat.getTime() + 2 * 86_400_000);
console.log("local + 48h:", plus2.toString().slice(0, 24)); // not Monday midnight!

const utc = Date.UTC(2026, 9, 24);
console.log("UTC + 48h:  ", new Date(utc + 2 * 86_400_000).toISOString().slice(0, 10));

// Output:
// local + 48h: Sun Oct 25 2026 23:00:00
// UTC + 48h:   2026-10-26

Starting from local midnight on Saturday, 48 hours later is 11 PM on Sunday, not Monday. If your code then reads the date with getDate(), a whole day disappears. The same code works fine in Bangladesh or Japan, where there's no daylight saving time, which is why the bug often isn't caught until a British user reports it.

UTC has no clock changes, so every UTC day is exactly 86,400,000 milliseconds. The fix is to turn each date string into UTC midnight with Date.UTC(), do all the stepping there, and turn it back into a string with toISOString(). The date never touches the user's time zone.

Step 1: parse and format dates safely

// dates.ts: "YYYY-MM-DD" strings in, plain day maths in UTC
export const DAY = 86_400_000;

export function parseDate(s: string): number {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
  if (!m) throw new RangeError(`Not a YYYY-MM-DD date: ${s}`);
  const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
  if (formatDate(t) !== s) throw new RangeError(`Not a real date: ${s}`); // 2026-02-30
  return t;
}

export function formatDate(t: number): string {
  return new Date(t).toISOString().slice(0, 10);
}

parseDate() also catches impossible dates. Date.UTC(2026, 1, 30) doesn't fail. It quietly rolls over to 2 March. Formatting the result and comparing it with the input catches that, so "2026-02-30" throws an error instead of giving a wrong count. The browser's <input type="date"> always gives this YYYY-MM-DD format, whatever the user's display format is, which is why I use it in the calculator. My guide to HTML form input types and validation covers that input and its min and max attributes.

Step 2: count and add working days

// workdays.ts: count and add UK working days
import data from "./bank-holidays.json";
import { DAY, formatDate, parseDate } from "./dates";

export type Region = "england-and-wales" | "scotland" | "northern-ireland";
type Holiday = { title: string; date: string; notes: string };
const HOLIDAYS = data as Record<Region, Holiday[]>;

const WEEKDAYS = new Set([1, 2, 3, 4, 5]); // Monday to Friday

export function countWorkingDays(start: string, end: string, region: Region) {
  let a = parseDate(start);
  let b = parseDate(end);
  if (a > b) [a, b] = [b, a];
  const holidays = new Map(HOLIDAYS[region].map((h) => [h.date, h]));
  let working = 0;
  const skipped: Holiday[] = [];

  for (let t = a; t <= b; t += DAY) {
    if (!WEEKDAYS.has(new Date(t).getUTCDay())) continue; // weekend
    const holiday = holidays.get(formatDate(t));
    if (holiday) skipped.push(holiday);
    else working++;
  }
  return { working, skipped };
}

// Like Excel's WORKDAY(): the start date itself is not counted
export function addWorkingDays(start: string, days: number, region: Region): string {
  const holidays = new Set(HOLIDAYS[region].map((h) => h.date));
  const step = days < 0 ? -DAY : DAY;
  let t = parseDate(start);
  let left = Math.abs(days);
  while (left > 0) {
    t += step;
    if (WEEKDAYS.has(new Date(t).getUTCDay()) && !holidays.has(formatDate(t))) left--;
  }
  return formatDate(t);
}

A few decisions are hidden in there:

  • Both ends are included when counting, like Excel's NETWORKDAYS(). Monday to Friday of one week is 5 working days, not 4. The real app has checkboxes to leave out the start or end date.
  • The start date isn't counted when adding, like Excel's WORKDAY(). One working day after a Friday is the Monday, which is what "reply within 1 working day" means.
  • Negative numbers go backwards, which is handy for "10 working days before the deadline".
  • The holidays are a Map or Set, so checking each day is instant, even for a range of several years.
  • Skipped holidays are returned, not just counted. People trust a result more when they can see "Christmas Day" and "Boxing Day (substitute day)" listed under it.

Here it is running against the real gov.uk data:

import { addWorkingDays, countWorkingDays } from "./workdays";

const dec = countWorkingDays("2026-12-01", "2026-12-31", "england-and-wales");
console.log("December 2026, England and Wales:", dec.working);
console.log("Skipped:", dec.skipped.map((h) => `${h.date} ${h.title}${h.notes ? ` (${h.notes})` : ""}`));

console.log("Same month, Scotland:", countWorkingDays("2026-12-01", "2026-12-31", "scotland").working);
console.log("17 March 2027, NI:", countWorkingDays("2027-03-17", "2027-03-17", "northern-ireland").working);
console.log("Whole of 2027, England and Wales:", countWorkingDays("2027-01-01", "2027-12-31", "england-and-wales").working);

console.log("5 working days after 24 Dec 2026:", addWorkingDays("2026-12-24", 5, "england-and-wales"));
console.log("1 working day after 31 Dec 2026, Scotland:", addWorkingDays("2026-12-31", 1, "scotland"));
console.log("10 working days before 5 Jan 2027:", addWorkingDays("2027-01-05", -10, "england-and-wales"));

try { countWorkingDays("2026-02-30", "2026-03-01", "scotland"); } catch (e) { console.log("Error:", (e as Error).message); }

// Output:
// December 2026, England and Wales: 21
// Skipped: [
//   '2026-12-25 Christmas Day',
//   '2026-12-28 Boxing Day (Substitute day)'
// ]
// Same month, Scotland: 21
// 17 March 2027, NI: 0
// Whole of 2027, England and Wales: 253
// 5 working days after 24 Dec 2026: 2027-01-05
// 1 working day after 31 Dec 2026, Scotland: 2027-01-05
// 10 working days before 5 Jan 2027: 2026-12-17
// Error: Not a real date: 2026-02-30

December 2026 has 23 weekdays. Christmas Day is a Friday, and Boxing Day falls on a Saturday, so the bank holiday moves to Monday 28 December. That leaves 21 working days. Scotland gets the same 21 for December, but its list also has St Andrew's Day (30 November) and a substitute for 2 January 2027, so 1 working day after 31 December 2026 is Tuesday 5 January there. In Northern Ireland, St Patrick's Day on Wednesday 17 March 2027 isn't a working day. For the whole of 2027, England and Wales has 261 weekdays minus 8 bank holidays: 253 working days.

Step 3: get the bank holidays from gov.uk

The gov.uk JSON is public, needs no API key, and is published under the Open Government Licence. It has one object per region, each with an events array of { title, date, notes, bunting }. The notes field says "Substitute day" when a holiday has moved.

I don't fetch it in the browser. The holidays only change when the government announces new ones, so a request on every visit would just slow the page down and add a point of failure. Instead, a small Node script runs before every build and saves a slim copy into the project:

// scripts/update-holidays.mjs: runs before every build ("prebuild" in package.json)
import { readFile, writeFile } from "node:fs/promises";

const FILE = new URL("./bank-holidays.json", import.meta.url);
const REGIONS = ["england-and-wales", "scotland", "northern-ireland"];

try {
  const res = await fetch("https://www.gov.uk/bank-holidays.json", { signal: AbortSignal.timeout(10_000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  for (const r of REGIONS) {
    if (!Array.isArray(data[r]?.events) || data[r].events.length === 0) throw new Error(`missing ${r}`);
  }
  const slim = Object.fromEntries(
    REGIONS.map((r) => [r, data[r].events.map(({ title, date, notes }) => ({ title, date, notes }))]),
  );
  const next = JSON.stringify(slim, null, 1) + "\n";
  const old = await readFile(FILE, "utf8").catch(() => "");
  if (next !== old) await writeFile(FILE, next);
  console.log(`bank holidays: ${next === old ? "unchanged" : "updated"} (${REGIONS.map((r) => slim[r].length).join("/")})`);
} catch (err) {
  console.warn(`bank holidays: using the committed copy (${err.message})`);
}

// Output:
// bank holidays: unchanged (83/94/103)

In the repo the file is scripts/update-holidays.mjs, it writes to src/data/bank-holidays.json, and package.json has "prebuild": "node scripts/update-holidays.mjs", so npm runs it automatically before next build. If you haven't used top-level await with fetch() before, my JavaScript async/await tutorial explains how it works.

Three details make it safe to run on every deploy:

  • It never breaks the build. If gov.uk is down or slow (there's a 10-second timeout with AbortSignal.timeout()), it logs a warning and the committed copy is used.
  • It checks the data before saving. An empty or reshaped response won't wipe out the holidays.
  • It only writes when something changed, so the file in git stays the same until gov.uk adds a new year.

The data only goes a couple of years ahead. When I checked it on 4 October 2026, it ran up to the end of 2028. The calculator warns you when a range goes past the last published year, because counting without holidays would silently give a number that's too high.

Step 4: the pages

The app is Next.js with the App Router, and every page is static. Next.js imports the JSON file directly, so the holidays are baked into the HTML and JavaScript at build time. The /bank-holidays/2027 style pages are made with generateStaticParams(), and each one shows every region's holidays and the working days in each month, all from the same countWorkingDays() function. The calculator page has to know "today" to show the next bank holidays, so that's set after the page loads, using the Europe/London time zone with Intl.DateTimeFormat.

I tested the core functions with Vitest (16 tests, including the clocks-going-back case), then ran a headless Chrome test against the live site to check that the form, the region switch and the "add working days" mode give the same answers as the unit tests.

FAQ

Can I use date-fns instead?

Yes. differenceInBusinessDays() and addBusinessDays() in date-fns handle weekends, but you still need to remove bank holidays yourself, and those functions work in local time. For a small tool, the 40 lines above are easier to read than a dependency.

Does this work for other countries?

The loop does. Swap the holiday list and, if needed, the WEEKDAYS set. For example, use Sunday to Thursday for countries with a Friday–Saturday weekend.

Conclusion

Counting UK working days in JavaScript comes down to three rules: step through days in UTC, skip weekends with getUTCDay(), and skip dates from the official gov.uk bank holiday list. Fetch that list at build time with a safe fallback rather than on every visit. You can try it in the UK working days calculator, or read the full code and tests on GitHub.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

How to Work Out Your UK Degree Classification
Coding tips

How to Work Out Your UK Degree Classification

Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.

October 4, 2026 · 10 min read