Every codebase I have worked in eventually grows a function called something like addBusinessDays. It usually starts as four lines, and it is usually wrong in a way nobody notices until a customer complains.
Here is the four-line version:
function addBusinessDays(date, n) {
const d = new Date(date);
while (n > 0) {
d.setDate(d.getDate() + 1);
if (d.getDay() !== 0 && d.getDay() !== 6) n--;
}
return d;
}
It skips Saturdays and Sundays. It is fine until December.
The part that bites
Ask that function for three business days after Christmas Eve 2026 and it says 28 December, because it has never heard of Christmas. The real answer is 30 December.
GET /add?date=2026-12-24&days=3&jurisdiction=US
{
"date": "2026-12-30",
"skipped": [
{ "date": "2026-12-25", "type": "holiday", "name": "Christmas Day" },
{ "date": "2026-12-26", "type": "weekend" },
{ "date": "2026-12-27", "type": "weekend" }
]
}
Two days off is not a rounding error when it is an SLA, a settlement date, or a promised delivery window.
The part that bites even after you add a holiday list
This is the one I want to convince you about, because bolting a list of holidays onto the naive loop feels like a fix and is not.
US federal holidays move. When a fixed-date holiday lands on a weekend, the observed day shifts. In 2026, 4 July falls on a Saturday, so the holiday is observed on Friday 3 July — a day that is not on anyone's "holidays are these dates" list, and a day your loop will happily count as a working day.
One business day after 2 July 2026:
GET /add?date=2026-07-02&days=1&jurisdiction=US
{
"date": "2026-07-06",
"skipped": [
{ "date": "2026-07-03", "type": "holiday", "name": "Independence Day" },
{ "date": "2026-07-04", "type": "weekend" },
{ "date": "2026-07-05", "type": "weekend" }
]
}
The answer is the following Monday. A naive loop with a hardcoded 07-04 in it returns Friday 3 July — a federal holiday, and a day the banks are shut.
Thanksgiving has the opposite shape: it is not a fixed date at all, it is the fourth Thursday, so any hardcoded date is wrong every year.
GET /add?date=2026-11-25&days=2&jurisdiction=US
{
"date": "2026-11-30",
"skipped": [
{ "date": "2026-11-26", "type": "holiday", "name": "Thanksgiving Day" },
{ "date": "2026-11-28", "type": "weekend" },
{ "date": "2026-11-29", "type": "weekend" }
]
}
Note what is not in that list: 27 November is not skipped. The Friday after Thanksgiving is not a federal holiday, even though half the country behaves as though it is. If your rule says otherwise, that is a business policy and you should encode it deliberately rather than discovering it by accident.
Return the reasoning, not just the answer
The thing I would push hardest on, whatever you end up using: make the function explain itself.
A date on its own is unfalsifiable. When support asks why a customer's refund landed on the 30th and not the 28th, "2026-12-30" gives you nothing, and you end up re-deriving the calendar by hand in a Slack thread. A skipped[] array answers the question in one paste, and it makes your tests readable — you assert on why a day was excluded, not merely that the output changed.
It is also the difference between a bug you can find and a bug you cannot. If a holiday list is stale, a plain date looks perfectly reasonable while being wrong. A reason array shows you the day it thought was Christmas.
Things worth deciding on purpose
- Whose calendar? Federal, NYSE and your own office are three different answers. NYSE closes on Good Friday; the federal government does not.
- Half-days. Markets close early on Christmas Eve and the day after Thanksgiving. If you are computing settlement, that may matter.
- Which end counts. Is "three business days from Monday" inclusive of Monday? Both conventions exist. Pick one, write it down, and put it in the function name if you can.
- Time zones. A "date" that is really a UTC timestamp will silently be the wrong day for anyone west of Greenwich. Business-day math should take a plain calendar date.
If you want to skip writing it
I got tired of re-implementing this, so I built it as a small self-contained API. It runs on Cloudflare Workers, computes offline with no upstream holiday service, and returns the skipped[] reasoning shown above. It has endpoints for business-day add, diff, next, holiday lookup, T+N settlement, and batch.
It is free to hit and needs no key — the examples in this post are live calls you can run right now:
- https://bizcal-api.pages.dev/add?date=2026-12-24&days=3&jurisdiction=US
- https://bizcal-api.pages.dev/openapi.json
Disclosure, since it is mine: if you would rather run it inside your own infrastructure than call somebody else's service — which is a very reasonable thing to want for date math this load-bearing — the full source kit is a paid product at $49: https://craniusmaximus.gumroad.com/l/sijflxg. The hosted API above stays free either way, and nothing in this post depends on buying anything.
If you take one thing from this: whatever you use, make it return the reasons. The date is the easy half.
Related: public holiday tables for 17 jurisdictions, each generated from the same live API — useful if you want to eyeball the dates your own implementation should be skipping.
Top comments (0)