We build a tool that turns a few form fields into a finished resignation or notice letter. The single most important string in that output is a date. A resignation letter states your last working day, and that date is the thing your employer acts on. So the date formatting code is four lines and it is the most dangerous four lines in the feature. function formatDate ( value ) { if ( ! value . trim ()) return '' ; const d = new Date ( value ); if ( Number . isNaN ( d . getTime ())) return value . trim (); return d . toLocaleDateString ( ' en-GB ' , { day : ' numeric ' , month : ' long ' , year : ' numeric ' }); } Reasonable looking. Here is what it does. The bug An HTML gives you "2026-03-01" . Feed that to new Date and run it in Los Angeles: TZ = America/Los_Angeles node -e "console.log(new Date('2026/03/01').toDateString())" Sun Mar 01 2026 Slashes give the right answer here and dashes give the wrong one, but only half of that is specified. The specification pins down exactly one thing: a date-only ISO string is UTC. Everything else, 2026/03/01 included, falls through to an implementation-defined fallback parser, so it happens to work in V8 and you cannot lean on it. Do not read this as "use slashes". Read it as "do not hand a string to Date at all". The UTC half is guaranteed by the spec, and it hits every negative UTC offset, which is the whole of the Americas. The consequence for the product is specific and bad. Someone resigning on the first of the month sends a letter naming the last day of the previous month. There is no crash to report, so the first time anyone finds out is a conversation with HR. The second failure: invalid dates that are not invalid The Number.isNaN guard implies dates either parse or fail. Try 30 February: TZ = America / Los_Angeles node - e " const s = new Date(2026, 9, 25); console.log(new Date(s.getTime() + 14*86400000).toDateString()); console.log(new Date(2026, 9, 25 + 14).toDateString()); " Sat Nov 07 2026 Sun Nov 08 2026 Two weeks after 25 October 2026 is either 7 or 8 November depending on which arithmetic you used. The millisecond version loses a day because the clocks go back on 1 November and one of those "days" was 25 hours long. Adding days is a calendar operation. Adding milliseconds is a physics operation. They agree most of the year, which is what makes this survive code review. The fix Stop letting Date parse strings, and stop letting UTC into a calendar calculation. function formatDate ( value ) { const v = String ( value ). trim (); if ( ! v ) return '' ; const m = /^ (\d{4}) - (\d{2}) - (\d{2}) $/ . exec ( v ); if ( ! m ) return v ; // "end of March" passes through untouched const [, y , mo , d ] = m . map ( Number ); const dt = new Date ( y , mo - 1 , d ); // local construction, no UTC shift // catch the silent rollover: 2026-02-30 comes back as March if ( dt . getFullYear () !== y || dt . getMonth () !== mo - 1 || dt . getDate () !== d ) return v ; return dt . toLocaleDateString ( ' en-GB ' , { day : ' numeric ' , month : ' long ' , year : ' numeric ' }); } Three changes, each fixing one of the failures above: Match the format explicitly. If it is not YYYY-MM-DD , it is not a date we are willing to interpret, so it goes through as typed. Construct from components. new Date(y, mo - 1, d) builds a local date. No parsing, no UTC, no offset arithmetic. Round-trip the result. Read the fields back off the constructed date and compare. If they disagree with the input, an overflow happened and the input was never a real date. For date arithmetic, use component maths for the same reason: const end = new Date ( start . getFullYear (), start . getMonth (), start . getDate () + 14 ); The runtime handles month and year boundaries and, critically, handles DST, because you asked for a calendar day rather than a fixed duration. The general rule Date conflates two things that behave differently: an instant on a timeline, and a day on a calendar. Timestamps, expiry, ordering, "how long ago" are instants, and UTC is correct for all of them. A birthday, a deadline, a last working day is a calendar day, has no time component, and does not exist on a timeline until you attach a zone to it. Almost every date bug we have put live came from storing a calendar day as an instant and then rendering it somewhere else. Two habits that catch it cheaply: Run your test suite under at least one negative UTC offset. TZ=America/Los_Angeles npm test in CI takes no extra time and turns this entire class of bug into a red build. If your only tests run in UTC, or in a European zone, this bug is invisible by construction. Test a DST boundary date on purpose. Pick the specific weekend your target zone changes and put it in a fixture. It is the only way that arithmetic gets exercised. We found all three of these in one small function whose whole job was to print one line. The resignation letter template tool it belongs to is free and needs no account, and the date it prints is now the date the person actually picked, which was always the only requirement.

new Date('2026-03-01') is 28 February if your user is in California
Moustafa Tarabya

