DEV Community

Moustafa Tarabya
Moustafa Tarabya

Posted on

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

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' });
}
Enter fullscreen mode Exit fullscreen mode

Reasonable looking. Here is what it does.

The bug

An HTML <input type="date"> 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').toLocaleDateString('en-GB',{day:'numeric',month:'long',year:'numeric'}))"
28 February 2026
Enter fullscreen mode Exit fullscreen mode

Same code in Helsinki:

1 March 2026
Enter fullscreen mode Exit fullscreen mode

The user picked 1 March. The letter says 28 February. Nothing threw, nothing logged, and
the developer in a positive UTC offset never sees it.

Why

ECMAScript specifies that a date-only ISO string is parsed as UTC. "2026-03-01"
becomes midnight UTC. toLocaleDateString then renders it in the runtime's local zone. In
Los Angeles, midnight UTC on 1 March is 4pm on 28 February.

Any format that is not date-only ISO is parsed as local time instead:

$ TZ=America/Los_Angeles node -e "console.log(new Date('2026/03/01').toDateString())"
Sun Mar 01 2026
Enter fullscreen mode Exit fullscreen mode

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 "console.log(new Date('2026-02-30').toDateString())"
Sun Mar 01 2026
Enter fullscreen mode Exit fullscreen mode

Not Invalid Date. Date rolls the overflow forward, so an impossible day silently
becomes a real day in the following month. The guard never fires, because there is nothing
to catch. A typed-in date of 2026-02-31 produces a confident, wrong, plausible answer.

The other side of the same coin is that real invalid input does need handling, but not by
rejecting it. Somebody typing end of March into a free text field is being reasonable.
The fallback in that function returns their string untouched, and I would keep that. Do not
punish a human for not using the date picker. Just do not confuse "I could not parse this"
with "this parsed to something sensible".

The third failure: two weeks is not 1,209,600,000 milliseconds

A notice-period feature wants to offer "two weeks from today". The tempting arithmetic:

const end = new Date(start.getTime() + 14 * 86400000);
Enter fullscreen mode Exit fullscreen mode

Run it across a daylight-saving boundary:

$ 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
Enter fullscreen mode Exit fullscreen mode

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' });
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)