dayjs.tz(ms, 'UTC').startOf('day') returns prior-day UTC midnight on hosts with DST
- Dominant language
- JavaScript
- Stars
- 48.7k
- Forks
- 2.5k
- PR merge metrics
- No merged PRs in 30d
Description
**Describe the bug**
`dayjs.tz(ms, 'UTC').startOf('day')` returns the start of the previous day in UTC instead of the correct day when the host's local timezone has a DST transition on the date being processed.
The Dayjs returned by `dayjs.tz(ms, 'UTC')` is supposed to be pinned to UTC for chained operations, but `startOf('day')` appears to fall back to host-local-time semantics. Using the UTC plugin directly (`dayjs.utc(ms)`) produces the correct result.
**Reproducing test case**
Run with the host's `TZ` set to a zone that has a DST transition on 2024-11-03 (e.g. `TZ=America/Los_Angeles`):
```javascript
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
const timezone = require('dayjs/plugin/timezone');
dayjs.extend(utc);
dayjs.extend(timezone);
// 2024-11-03 23:00 UTC. Local TZ America/Los_Angeles ends DST at
// 02:00 PDT on this date (PDT -> PST), so 2024-11-03 straddles a
// DST transition in local time.
const ms = new Date('2024-11-03T23:00:00Z').getTime();
const buggy = dayjs.tz(ms, 'UTC').startOf('day').valueOf();
const correct = dayjs.utc(ms).startOf('day').valueOf();
const expected = new Date('2024-11-03T00:00:00Z').getTime();
console.log('buggy ->', new Date(buggy).toISOString());
console.log('correct ->', new Date(correct).toISOString());
console.log('expected->', new Date(expected).toISOString());
// Expected output:
// buggy -> 2024-11-03T00:00:00.000Z
// correct -> 2024-11-03T00:00:00.000Z
// expected-> 2024-11-03T00:00:00.000Z
//
// Actual output (on a host with US DST on 2024-11-03):
// buggy -> 2024-11-02T23:00:00.000Z <-- off by one hour
// correct -> 2024-11-03T00:00:00.000Z
// expected-> 2024-11-03T00:00:00.000Z
```
**Expected behavior**
`dayjs.tz(ms, 'UTC').startOf('day').valueOf()` should equal `dayjs.utc(ms).startOf('day').valueOf()` — i.e., the start of the UTC day containing `ms`. The choice of host-local timezone should not affect the result when the chained timezone is explicitly UTC.
**Additional notes**
- The bug only reproduces when the host's local timezone has a DST transition on the date under test. UTC hosts and non-DST hosts hide it.
- Other IANA-named zones (`US/Pacific`, `US/Eastern`, `Europe/Berlin`, etc.) work correctly even on DST host dates — sweeping a fix-test matrix across `US/Pacific`, `US/Eastern`, `US/Hawaii`, and `UTC` on 2024-11-03, only the UTC case fails.
- Symptom in real code: a day-by-day boundary walk where each iteration computes `dayjs.tz(watermark, 'UTC').startOf('day').add(1, 'day')` gets pinned at a single moment, because `startOf('day')` returns the prior day's midnight and `+1 day` lands exactly on `watermark`.
- The `.startOf('millisecond')` workaround that fixes #2957 does **not** fix this bug — confirmed by direct probing. The two issues share a "chained operations don't respect `.tz()`" root cause but they hit different internal code paths.
**Information**
- Day.js Version: 1.11.21
- OS: macOS 25.4.0
- Browser: Node 24.16.0
- Time zone: `America/Los_Angeles` (host); requested zone in the buggy call: `UTC`
Contributor guide
Research direction
Start with the UTC and timezone plugin entry points used by `dayjs.tz` and `dayjs.utc`, then reproduce the case with `TZ=America/Los_Angeles` and the supplied 2024-11-03 timestamp. Add a regression test covering `dayjs.tz(ms, 'UTC').startOf('day')` against `dayjs.utc(ms).startOf('day')`; done means both values equal the expected UTC midnight across the stated host zones.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100