Date Math Without Bugs: Diffs, Business Days, and JavaScript Date Lies
Subtracting two dates feels like it should be the easiest line of code in the application. Then the QA ticket comes in: the contract says "14 business days" and the page says 17. The deadline calculator shipped on January 1st and broke on March 13th because DST shifted the result by an hour. The age calculator says someone born on February 29th turns 8 years old every leap year only.
Every project I have shipped that touches dates eventually meets the same bug. The subtraction is correct. The display is correct. The math the human in their head is doing does not match either one — and the human is the customer.
Every date diff has two correct answers
Take January 31st and February 28th. How long is that span?
- Total days — 28 days. The raw millisecond difference divided by
86_400_000. - Calendar breakdown — 0 years, 0 months, 28 days. Or, depending on how you count, 1 month minus 3 days.
Both are right. They answer different questions. Total days answers "how many sleeps until then." Calendar breakdown answers "tell me my age in years and months." The bugs happen when an interface promises one and computes the other.
The calendar borrow algorithm, explained
Humans count durations by borrowing. "Three years, two months, eleven days" comes from subtracting the units in order — seconds, minutes, hours, days, months, years — and borrowing from the next-larger unit whenever the result goes negative.
// 두 Date 객체 사이를 사람이 읽는 단위로 분해
function breakdown(start, end) {
let years = end.getFullYear() - start.getFullYear();
let months = end.getMonth() - start.getMonth();
let days = end.getDate() - start.getDate();
let hours = end.getHours() - start.getHours();
let minutes = end.getMinutes() - start.getMinutes();
let seconds = end.getSeconds() - start.getSeconds();
// 작은 단위부터 borrow
if (seconds < 0) { seconds += 60; minutes -= 1; }
if (minutes < 0) { minutes += 60; hours -= 1; }
if (hours < 0) { hours += 24; days -= 1; }
// 일 → 월 borrow는 "이전 달의 일 수"가 필요함
if (days < 0) {
const prevMonth = new Date(end.getFullYear(), end.getMonth(), 0);
days += prevMonth.getDate();
months -= 1;
}
if (months < 0) { months += 12; years -= 1; }
return { years, months, days, hours, minutes, seconds };
}The subtle part is the day-to-month borrow. You cannot subtract 30 every time — February has 28 (or 29), April has 30, and December has 31. JavaScript's new Date(year, month, 0) returns the last day of the previous month thanks to date normalization, which is exactly the value you need to borrow.
JavaScript's hidden lies
new Date(year, month, day) — month is zero-based
new Date(2026, 1, 15) is February 15, not January 15. Years are 1-based, months are 0-based, days are 1-based. Decades of bug reports later, this is still the API.
Date parsing is implementation-defined
new Date('2026-05-13') is parsed as UTC midnight. new Date('2026/05/13') is parsed as local midnight. The diff between those two values is your local UTC offset — which is why "the date is one day off" bugs keep happening when string formats change.
Always either send ISO 8601 with an explicit timezone, or use the numeric constructor. Pair it with a timestamp converter when debugging — pasting the raw epoch number tells you immediately whether the value was UTC or local.
DST silently changes day length
The naive "divide by 86_400_000" assumes every day is 24 hours. Twice a year, in regions that observe daylight saving time, a day is 23 or 25 hours long. A function that computes "days between" by raw subtraction will report a fractional day at every DST boundary.
// 위험: DST 경계를 넘으면 결과가 23.95 또는 24.04로 떨어짐
function daysBetweenWrong(a, b) {
return (b - a) / 86_400_000;
}
// 안전: 시간 부분을 잘라내고 비교
function daysBetweenSafe(a, b) {
const dayA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
const dayB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
return Math.round((dayB - dayA) / 86_400_000);
}Snapping both dates to UTC midnight before subtracting bypasses DST entirely. The result is integer days, which is what the customer asked for in the first place.
Business days are surprisingly hard
"Business days" usually means Monday through Friday. The simple version is a loop that walks every day between the endpoints:
// 두 날짜 사이의 영업일 — 단순 루프, 365일 정도면 충분히 빠름
function businessDays(start, end) {
const a = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const b = new Date(end.getFullYear(), end.getMonth(), end.getDate());
if (a > b) return businessDays(b, a);
let count = 0;
const cursor = new Date(a);
while (cursor <= b) {
const day = cursor.getDay();
if (day !== 0 && day !== 6) count += 1;
cursor.setDate(cursor.getDate() + 1);
}
return count;
}The math version skips the loop and runs in constant time, but only for the basic Mon-Fri rule. For a thousand-row report the optimization pays off. For everything else, the loop is fast enough and the code is shorter:
// 상수 시간 영업일 계산 — 공휴일은 별도로 차감
function businessDaysFast(start, end) {
const a = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const b = new Date(end.getFullYear(), end.getMonth(), end.getDate());
const totalDays = Math.round((b - a) / 86_400_000) + 1;
const fullWeeks = Math.floor(totalDays / 7);
let extra = totalDays % 7;
// 시작 요일 기준으로 잔여 일수 중 주말 카운트
let weekend = 0;
let dow = a.getDay();
for (let i = 0; i < extra; i++) {
if (dow === 0 || dow === 6) weekend += 1;
dow = (dow + 1) % 7;
}
return fullWeeks * 5 + (extra - weekend);
}Holidays are political
National holidays change by country and year. South Korea has substitute holidays, the US has Juneteenth (federal as of 2021), the UK shifts bank holidays around weekends. Hard-coding a holiday list is fine for one country and one year; everything else needs a maintained dataset.
For a single-country app, list the holidays in a config file and subtract from the business-day count. For multi-country apps, the libraries date-holidays and holidays-kr cover most regions. Whichever path you take, surface the holiday list in the UI — users always want to know which dates the system considers a holiday.
Time zones are where most apps get it wrong
A date diff in user's local time can differ from the same diff in UTC by a full day. Birthdays, contract terms, and deadlines should be computed in a single time zone — usually the user's — and then formatted.
Three rules that prevent most of the bugs:
- Store as UTC, compute in the user's zone. Save the original moment in ISO 8601 with a Z suffix. Compute the diff after converting both endpoints to the target zone.
- Never use Date.UTC for the user-facing diff.Date.UTC ignores DST, which is great for math but wrong for "how long has it been since I logged in?" questions that need wall-clock semantics.
- Surface the time zone in the UI.A footer line that reads "all times in Asia/Seoul" saves an entire class of support tickets. Pair the date input with a quick timezone converter when the user might be elsewhere.
When to use a library
Native Date is enough for any single-zone, English-only, no-holiday case. As soon as you cross into formatting in multiple zones, parsing user-typed dates, or doing math across calendars, reach for a library.
- date-fns — tree-shakable, immutable, function-style. Smallest bundle when you only need a few helpers. Most projects start here.
- Luxon — better time-zone story via Intl. Worth it when the app deals with global users or shifts data across zones.
- Temporal — TC39 proposal that fixes most of
Date's history. Stage 3, polyfill-only as of 2026 in most browsers. Worth tracking. The API is cleaner than every existing library. - moment.js — legacy. The project is in maintenance mode and recommends alternatives. Migrate at the next opportunity.
The edge cases that show up in production
- February 29 birthdays — the breakdown algorithm says someone born in 2000 turns 26 on March 1, 2026, but 1 day later than expected. Pick a policy (March 1 vs February 28) and document it in the UI.
- Month-end edge cases— "one month after January 31" can be February 28, March 3, or undefined depending on the library. Be specific about what your code does.
- Negative spans— when end precedes start, return the magnitude and flag direction separately. Hiding the sign in the result invariably leads to a UI bug where the customer sees "-3 days" in production.
- Future DST changes — Brazil dropped DST in 2019, Lebanon changed twice in 2023. Operating system updates ship new zone data; pinning your runtime version freezes those rules. Keep the IANA tz database current.
Storing dates: where the SQL ↔ JS roundtrip leaks
A surprising number of date bugs are not math bugs but storage bugs. The same date goes in one way and comes out shifted, because the layers in between do not agree on what kind of date they are storing.
-- PostgreSQL의 세 가지 date/time 타입
DATE -- 시각 없음, 타임존 없음 ("2026-05-14")
TIMESTAMP -- 시각 있음, 타임존 없음 ("2026-05-14 09:00:00")
TIMESTAMPTZ -- 시각 있음, UTC로 저장, 표시 시 세션 TZ 변환
-- 같은 INSERT, 다른 결과
INSERT INTO events (occurred_at) VALUES ('2026-05-14 09:00:00');
-- TIMESTAMP 컬럼 → 그대로 저장, 어느 TZ인지 모름
-- TIMESTAMPTZ 컬럼 → 세션 TZ 기준으로 UTC 변환 후 저장
SET TIME ZONE 'Asia/Seoul';
INSERT INTO events VALUES ('2026-05-14 09:00:00'); -- 00:00:00 UTC로 저장
SET TIME ZONE 'UTC';
SELECT occurred_at FROM events; -- 2026-05-14 00:00:00 UTCThe JS side is no friendlier. new Date(rowFromDb.occurred_at) parses the database string as local time if there is no timezone suffix, and as UTC if the string ends in Z or an offset. The same query result becomes a different Date depending on which connector you use:
// node-postgres (pg) — TIMESTAMPTZ를 Date 객체로 반환, UTC 정확
const { rows } = await pool.query('SELECT occurred_at FROM events');
rows[0].occurred_at instanceof Date; // true
rows[0].occurred_at.toISOString(); // "2026-05-14T00:00:00.000Z"
// mysql2 — DATETIME을 문자열로 반환, TZ 없음
// new Date()로 감싸면 local TZ로 해석 → DB에 UTC로 저장했어도 9시간 오프셋
const [rows] = await pool.execute('SELECT occurred_at FROM events');
new Date(rows[0].occurred_at); // Asia/Seoul에서는 09:00:00 한국시각으로 잡힘
// 안전한 패턴: 서버는 항상 ISO 8601 문자열로 응답
res.json({ occurred_at: row.occurred_at.toISOString() });Three rules that prevent most storage bugs:
- Use TIMESTAMPTZ in Postgres (or store epoch milliseconds in MySQL). TIMESTAMP without timezone is a footgun in any production database.
- Send and accept ISO 8601 over HTTP. JSON has no native date type, so the contract is a string. Make sure it ends in Z or an explicit offset.
- Set the server timezone to UTC. Local times leak through cron jobs, log formatters, and DATE_TRUNC queries. UTC-only servers remove an entire bug class.
Wrapping up
Date math is a small surface area with a long history of small bugs. The big wins are small: snap to UTC midnight before counting days, do the calendar breakdown by borrowing like a human would, ship the time zone next to every date in the UI, and pick one library once a project crosses the trivial cases.
When the diff still does not look right, the fastest sanity check is dropping both dates into the date diff calculator. The breakdown view and the totals view sit side by side, which makes the algorithm difference obvious instead of theoretical.
Related Tools
Date Diff Calculator
Find years, months, days, hours, minutes, and business days between any two dates. Calendar breakdown and total counts.
Timestamp Converter
Convert Unix timestamps to human-readable dates and vice versa.
Time Zone Converter
Convert a single timestamp into every major world time zone at once with daylight saving support.
Related Articles
How to Generate Secure Passwords in 2026: A Complete Guide
Credential attacks now lean on GPU clusters and ML pattern guessing. What entropy, length, and randomness actually buy you, plus the password manager picks that hold up in 2026.
2025-12-15 · 8 min readData FormatsJSON vs YAML: When to Use What — A Developer's Guide
JSON wins on APIs; YAML wins on configs. Side-by-side syntax, parser behaviour, and where each fits across Kubernetes manifests, REST payloads, and GitHub Actions.
2025-12-28 · 10 min read