Get the Start of the Week in a Timezone in JavaScript

To get the start of the week, subtract (dayOfWeek - firstDay) days from a Temporal.PlainDate, then take startOfDay() in the zone. Part of Working with ZonedDateTime Objects.

Why this scenario is tricky

"Start of the week" hides two independent decisions that a correct implementation must make explicit. The first is which day the week starts on, and there is no universal answer: ISO 8601 and much of the world start on Monday, the United States and several other locales start on Sunday, and a few regions start on Saturday. Hard-code the wrong one and every weekly bucket is off by a day for a large fraction of your users. The second decision is what "start" means in time terms — almost always the first instant of that day in a specific zone — and that instant is not always midnight, because on a spring-forward day the clock can jump straight from 11:59pm to 1am, so the day's true start is the first valid wall-clock moment, not a literal 00:00.

Legacy Date gives you neither decision cleanly. It has no notion of a configurable first day, and computing "midnight" by zeroing the time fields operates in the host machine's zone, so a report generated on a UTC server buckets a user's week differently than the user's own browser would. The result is weekly aggregates that disagree between environments and drift around DST, which is exactly the kind of inconsistency that erodes trust in a dashboard.

Temporal separates the two concerns. The first-day choice is plain modular arithmetic on the ISO dayOfWeek, and the zone-correct start is startOfDay() on a ZonedDateTime, which returns the first actual instant of the civil day in that zone — handling the DST edge for you. Keeping the calendar step (PlainDate) separate from the zone step (startOfDay in a zone) is what makes the whole thing correct and testable.

Two decisions make this locale- and zone-sensitive: which day the week starts on (Monday in ISO, Sunday in the US) and the fact that the week's first instant is that day's startOfDay, which is not always midnight on a DST-transition day. Compute the civil date first, then resolve the instant in the zone.

Week start depends on locale and zoneAssuming Sunday and midnight both biteWeek start depends on locale and zonesubtract getDay(), set 00:00wrong first day + DST gapdayOfWeek math + startOfDaycorrect instantPick the first day explicitly, then resolve startOfDay in the zone.

Minimal working solution

The civil part is (dayOfWeek - firstDay + 7) % 7, the number of days to step back from the given date to reach the most recent week start. With firstDay = 1 (Monday), a Wednesday (dayOfWeek 3) steps back 2 days; a Monday steps back 0; a Sunday (dayOfWeek 7) steps back 6, correctly treating Sunday as the end of an ISO week rather than its start. The + 7 before the modulo keeps the result non-negative when the current day is earlier in the week than the first day, which is the case that a naive subtraction gets wrong. Subtracting that many days from the PlainDate yields the week-start date.

Because this runs on a zoneless PlainDate, the week-start date is unambiguous and host-independent — it is a pure calendar calculation. That is the right layer to compute "which date does this week begin on," deferring any question of instants until you actually need a moment in time. Keeping it civil also makes the firstDay parameter trivially adjustable without touching zone logic.

import { Temporal } from '@js-temporal/polyfill';
function weekStart(d: Temporal.PlainDate, firstDay = 1 /*Mon*/) {
  const back = (d.dayOfWeek - firstDay + 7) % 7;
  return d.subtract({ days: back });
}

Back to the week startSubtract (dayOfWeek - firstDay) mod 7Back to the week startPlainDate(dow-first+7)%7week start date

Full production version

When you need the first instant of the week — for a query lower bound, say — layer the zone step on top: take the week-start PlainDate, attach the zone with toZonedDateTime(zone), and call startOfDay(). startOfDay returns the earliest valid clock time of that civil day in that zone, which is normally 00:00 but is the post-transition time (often 01:00) on a spring-forward day when midnight itself does not exist. Using startOfDay instead of hard-coding a midnight PlainTime is what makes the boundary correct on transition days, and it is the detail most hand-rolled implementations miss.

Anchoring the week start in a specific zone — rather than in the host zone — is essential for correct bucketing. A weekly report for users in Sydney must bucket by Sydney weeks, so the same civil dates map to the same instants regardless of where the server runs. Passing the reporting zone explicitly means a job running in UTC and a browser in Australia agree on where each week begins and ends. Pair this with a half-open upper bound (the next week's start) and you get a clean [start, nextStart) range that tiles the calendar with no gaps or overlaps, which is the same interval discipline used throughout this topic.

import { Temporal } from '@js-temporal/polyfill';
function weekStartInstant(date: Temporal.PlainDate, zone: string, firstDay = 1) {
  const back = (date.dayOfWeek - firstDay + 7) % 7;
  return date.subtract({ days: back }).toZonedDateTime(zone).startOfDay();
}

First instant of the weekCivil week start, then startOfDay in zoneFirst instant of the weekdate+zoneweek starttoZonedDateTimestartOfDay

Verification snippet

Test the first-day parameter across the week: with Monday as the first day, assert a Wednesday steps back to Monday, a Monday returns itself, and a Sunday steps back six days to the Monday that began its ISO week. Then flip firstDay to Sunday and assert the same inputs produce the US-convention week starts, proving the parameter genuinely drives the calculation rather than being ignored. These cases pin down the modular arithmetic in both conventions.

The zone-sensitive assertions are the ones that catch real bugs. Compute the week start's instant with startOfDay on a spring-forward Sunday and assert it is the first valid time of day, not a nonexistent midnight. Compute the same week start for a user zone while running the test under several TZ values and assert the resulting instant never changes, which proves the bucketing is anchored to the specified zone rather than the host. Finally, assert the half-open week range's upper bound equals the next week's start so adjacent weeks tile exactly.

Start of the Week assertionsKey cases assert correctlyAssertions that prove the edge caseWed, Mon-firstback to MondaySun, Sun-firststays SundaystartOfDayfirst instantDST week startnot 00:00

Common pitfalls

The first pitfall is assuming a first day of the week. Monday is the ISO default but a large share of users expect Sunday, so make firstDay a parameter and drive it from locale or product configuration rather than hard-coding it. The second is computing the week start's instant in the host zone — zeroing time fields on a local Date — which makes weekly buckets disagree between a UTC server and a user's browser; always anchor startOfDay in the explicit reporting zone. The third is hard-coding midnight as the week's start time, which is wrong on spring-forward days where midnight does not exist; startOfDay returns the correct first valid instant.

A subtler mistake is mixing the civil and zoned layers — for instance, converting to a zoned value first and then doing the day-stepping arithmetic on wall-clock fields that a transition has perturbed. Do the modular day math on the zoneless PlainDate, and only attach the zone at the final startOfDay step. Finally, remember that Sunday is dayOfWeek 7 in Temporal, not 0; carrying over the legacy Sunday-is-0 assumption shifts the whole calculation by a day.

Start of the Week pitfallsCommon mistakes and their fixesWrongRightassume Sunday startwrong in ISO localesexplicit firstDay paramlocale-correctset time to midnightDST gap invalidstartOfDay in zonevalid instant

Frequently Asked Questions

How do I get the start of the week for a date?

Subtract (dayOfWeek - firstDay + 7) % 7 days from a Temporal.PlainDate, where firstDay is 1 for Monday or 7 for Sunday. To get the exact first instant, call toZonedDateTime(zone).startOfDay() on the resulting date.

Does the week start on Monday or Sunday?

It depends on the locale — ISO 8601 and most of the world use Monday, while the US and some others use Sunday. Make it a parameter so the same function serves both, rather than hard-coding one convention.

How do I get the start of the week for a date in JavaScript?

Compute the days to step back with (dayOfWeek - firstDay + 7) % 7 on a Temporal.PlainDate and subtract them to get the week-start date. For the first instant of that week in a specific zone, attach the zone with toZonedDateTime(zone) and call startOfDay(), which returns the earliest valid clock time of the day — normally midnight, but the post-transition time on a spring-forward day. Keeping the calendar step zoneless and the instant step zone-aware makes it correct and host-independent.

How do I make the week start on Sunday instead of Monday?

Pass firstDay = 7 (Sunday's ISO dayOfWeek) — or more conventionally treat Sunday as the first day by adjusting the formula to (dayOfWeek % 7) — and the modular arithmetic shifts every week start accordingly. Because the first day varies by locale (Monday for ISO and much of the world, Sunday in the US, Saturday in some regions), make it a configurable parameter driven by locale or product settings rather than hard-coding one convention.

Why use startOfDay instead of setting the time to midnight?

Because midnight does not exist on every day. On a spring-forward daylight-saving day the clock can jump from 11:59pm straight to 1am, so a hard-coded 00:00 is an invalid wall time. startOfDay() on a ZonedDateTime returns the first actual instant of that civil day in the zone — the post-transition time when midnight is skipped — so your week boundary is always a real moment. It also anchors the boundary in the specified zone, keeping weekly buckets consistent across servers and browsers.