Article · Mar 13, 2026

Silent timezone bugs in JavaScript date arithmetic

`new Date().toISOString().split('T')[0]` returns the UTC date, not the user's local date. The bug, the fix, and why it only shows up in production.

A user emails support: “I logged six hours on Thursday and they are showing up under Wednesday.” You check the database. The entry is dated Wednesday. You check the workflow. It writes whatever date the frontend sends. You check the frontend. It is sending new Date().toISOString().split('T')[0]. You also check the timestamp on the entry. It was created at 8:14pm Eastern Time.

That is the bug. It is one line of code. It cost a freelancer most of a billing period before anyone noticed.

The one-line bug that ate a week of time-entry data

Here is the call that does the damage:

const today = new Date().toISOString().split('T')[0];

It reads like “give me today’s date as a YYYY-MM-DD string.” That is what most people think it does. What it actually does is:

  1. Create a Date object representing the current moment in time (correctly).
  2. Convert that moment to its UTC equivalent.
  3. Format the UTC equivalent as an ISO 8601 string (2026-01-22T01:14:00.000Z).
  4. Take the first ten characters.

Step 2 is the problem. If the user is in EST (UTC minus 5) at 8:14pm on Wednesday, the UTC equivalent is 1:14am on Thursday. The string is 2026-01-23T01:14:00.000Z. The first ten characters are 2026-01-23. The user just logged time for the next day.

A side-by-side comparison showing a user in EST at 8:14pm Wednesday on the left, the toISOString output of 2026-01-23T01:14:00Z on the right, with the date slice highlighted in red as the wrong value (2026-01-23 instead of the expected 2026-01-22).

The bug is silent for two reasons. The string looks right (it has ten characters in the right format). And dev environments often run in UTC, so the developer testing the feature in a local dev container never sees the shift. It surfaces in production the moment a user near midnight in any non-UTC zone tries to do something.

Why dev environments hide it

Dev containers (Docker, GitHub Codespaces, Vercel preview deployments) typically run on Linux servers configured to UTC. Vitest runs in node, and node respects TZ=UTC if the environment variable is set, which most CI containers do.

So when you write a test like:

test('records today', () => {
  const result = recordTime();
  expect(result.date).toBe(new Date().toISOString().split('T')[0]);
});

It passes. Both sides of the equality use the UTC date. Both are wrong by the same amount. The test catches nothing.

The only way to catch this in CI is to either (a) run the test under a non-UTC TZ env var, or (b) explicitly mock the user’s timezone via a library like mockdate plus Intl.DateTimeFormat overrides. Most teams do neither, so the bug ships.

What toISOString() actually does

From the MDN spec:

The toISOString() method of Date instances returns a string representing this date in the date time string format, a simplified format based on ISO 8601, which is always 24 or 27 characters long (YYYY-MM-DDTHH:mm:ss.sssZ or ±YYYYYY-MM-DDTHH:mm:ss.sssZ, respectively). The timezone is always UTC, as denoted by the suffix Z.

The Z suffix is “Zulu,” which is military shorthand for UTC. It is not optional. There is no way to make toISOString() return a non-UTC string. It is doing exactly what it is supposed to do; the misuse is treating it as if it knows about the user’s zone.

This is fine for storage (every moment in time should be stored as UTC) and fine for transport (server-to-server APIs should exchange UTC). It is wrong for display, and it is wrong for picking a calendar date the user thinks of as “today.”

The localToday() fix

The five-line replacement:

// Returns today's date in the user's local timezone as a YYYY-MM-DD string.
function localToday() {
  return new Intl.DateTimeFormat('en-CA').format(new Date());
}

Two things make this work.

Intl.DateTimeFormat (the Internationalization API, built into every modern browser and node since version 13) respects the runtime’s timezone by default. In the browser, that is the user’s OS timezone. In a server context, it is whatever TZ is set to.

The en-CA locale formats dates as YYYY-MM-DD natively, which is what you almost always want for storage and comparison. Other locales format differently (US is M/D/YYYY, UK is D/M/YYYY), so picking en-CA is a deliberate choice to get an ISO-like format with local zone awareness.

If you want to pin the formatter to a specific timezone regardless of the user’s machine (e.g. always show business dates in America/New_York), pass it as an option:

function todayInZone(timeZone) {
  return new Intl.DateTimeFormat('en-CA', { timeZone }).format(new Date());
}

That is the same primitive scaled up. Pass 'America/New_York' and you get the calendar date in that zone. Pass 'UTC' and you get back to the original behavior, if that is genuinely what you want.

Where else this pattern leaks

The toISOString().split('T')[0] pattern is one face of a larger class of bug. Same shape, different places to hide:

  • Database DATE columns. Postgres DATE has no timezone. When the application writes a calendar day, it has to pick the right one before the INSERT. Using toISOString().split('T')[0] here writes UTC-day to a “local-day” column. Same one-day skew.
  • Scheduled cron jobs. A “send daily digest at 6am local time” cron that runs on a UTC server with a UTC schedule fires at 6am UTC, which is 1am EST or 11pm Hawaii. Use a scheduler that respects user-local zones (or run the job hourly and have the handler check whether it is 6am in each user’s zone).
  • Calendar invites. The .ics file format encodes events in UTC by default. When the calendar app rebases to local time, off-by-one-hour errors hide here, especially around DST transitions.
  • CSV exports. When a user downloads “all entries for last week,” is “last week” the calendar week in the user’s zone or in UTC? The two often differ by a row at each boundary.

The pattern in all of these is the same: a calendar concept (a day, a week, a month) is being encoded as a moment in time. Calendar concepts are zone-dependent; moments in time are not. Decide which one you have before you serialize.

This is a class of failure that lines up with the silent-failure patterns I wrote about in silent failures in AI-built data pipelines: the system reports success because the call returned without error, while the data quietly drifts.

Pre-launch checklist for timezone-sensitive features

For any feature that records or displays calendar dates:

  1. Grep the codebase for toISOString and split('T')[0]. Every match is a candidate bug. Look at the surrounding code: if it is computing a calendar date for storage or display, replace with Intl.DateTimeFormat.
  2. Run one test with a non-UTC TZ. Something like TZ=America/New_York npm test. If anything that depends on “today” breaks, you have a hidden timezone assumption.
  3. Pick a column type deliberately. Calendar values go in DATE; moment-in-time values go in TIMESTAMPTZ. Mixing them is the long-term version of the same bug.
  4. Display always specifies the zone. Use Intl.DateTimeFormat with { timeZone } or the user’s locale; never toLocaleString without thinking about which zone is in scope.
  5. Schedule with care. Any “at X every day” workflow needs to know whose X it respects. If it is “the system’s UTC X,” document that. If it is “each user’s local X,” the scheduler needs the zone.

The audit takes an hour. The bug it prevents is the kind of thing that gets escalated because a user feels their data was lost. For more on the “report success while quietly losing data” class of bug, I wrote up the patterns I see most often in idempotent pipelines with natural-key fingerprints.

If you are building a product where users record time, schedule events, or report on calendar windows, and the team is not sure whether the dates are in the right zone, let’s talk. A timezone audit is a half-day of work and clears a class of incident permanently.

Frequently asked questions

Why does `new Date().toISOString().split('T')[0]` return the wrong date?

Because `toISOString()` always returns UTC. If the user is in EST (UTC minus 5) and clicks submit at 8pm local time, `new Date()` is correct (it knows the wall-clock time), but `toISOString()` converts to UTC, which is already the next day at 1am. Slicing the first ten characters gives you tomorrow's date in the user's frame of reference. The bug is silent because the call returns a string that looks right; only the date part is wrong by one day.

How do I get today's date in the user's local timezone in JavaScript?

Use the Intl.DateTimeFormat API with the `en-CA` locale (which formats as YYYY-MM-DD by default). The five-line helper is `const today = new Intl.DateTimeFormat('en-CA').format(new Date())`. Intl.DateTimeFormat respects the user's system timezone by default, so the returned string is the user's local date. For an explicit timezone (e.g. always EST regardless of where the user is), pass `{ timeZone: 'America/New_York' }` as the formatter option.

Does date-fns handle timezones correctly?

The core date-fns library treats dates as timezone-naive (it uses whatever zone the JS runtime is in). For explicit timezone handling, you need date-fns-tz, which adds `formatInTimeZone` and `zonedTimeToUtc`. Day.js has the same split with its timezone plugin. As of 2026 the recommended modern approach is the Intl API for formatting and the upcoming Temporal proposal (currently in TC39 Stage 3) for arithmetic. For now, Intl plus a thin wrapper covers most production needs.

When should I store dates as DATE versus TIMESTAMPTZ in Postgres?

Use TIMESTAMPTZ for any moment in time you might want to display in a user's local zone later (when something happened, when a job ran, when an email was sent). Use DATE for calendar-style values that are not tied to a moment (a user's birthday, the date a leave request applies to, the day a time entry counts toward). DATE has no timezone associated with it; the application is responsible for picking the right calendar day before it writes to the column.