Every engineering team eventually ships a date bug that only shows up for some users, only in some timezones, or only twice a year around a DST transition. These bugs are disproportionately expensive because they're intermittent and pass code review easily since the code looks correct on the developer's own machine, in the developer's own timezone, on a day that isn't near a transition.
This is a working reference for the patterns that actually prevent these bugs, not a restatement of "use UTC everywhere" without the specifics of how.

Photo by Rashed Paykary on Pexels
Store and Transmit in UTC, Always
The first rule everyone knows and a meaningful fraction of codebases still violate somewhere: database columns, API payloads, and log timestamps should be UTC. Convert to a local timezone only at the final rendering step, in the UI layer, as close to the human reading it as possible.
// Storing: always UTC, ISO 8601 with explicit Z
const now = new Date().toISOString(); // "2026-09-06T14:32:00.000Z"
// Rendering: convert at display time, not storage time
const localDisplay = new Intl.DateTimeFormat('en-US', {
timeZone: userTimezone, // e.g. "America/New_York"
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(isoString));
The mistake that keeps happening: a developer converts to local time for a log line or a cached value, and that local-time value gets stored or passed downstream instead of staying UTC. Once a local time leaks into storage without its offset attached, there's no way to reconstruct the original UTC instant reliably.
The Timezone Database Is the Source of Truth, Not a Fixed Offset Table
A timezone is not a fixed UTC offset. America/New_York is UTC-5 in winter and UTC-4 in summer, and the exact transition dates have changed multiple times historically as different jurisdictions changed their DST rules. Hardcoding "Eastern is UTC-5" is a bug waiting for the next DST transition to expose it.
// Wrong: fixed offset, breaks twice a year
const wrongOffset = -5 * 60; // hardcoded EST offset in minutes
// Right: let the IANA timezone database resolve the actual offset for that date
const correct = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
timeZoneName: 'short'
}).formatToParts(specificDate);
The IANA Time Zone Database is the canonical source every serious date library pulls from, including the browser and Node's built-in Intl APIs. Using named timezone identifiers instead of raw offsets means your code automatically inherits any future DST rule changes without a deploy.
DST Transitions Create Times That Don't Exist and Times That Exist Twice
Twice a year, a local time either skips forward (spring forward, an hour of wall-clock time that never happened) or repeats (fall back, an hour that happens twice). Code that naively schedules "2:30 AM local time" without handling this can either throw, silently shift, or fire twice depending on the library and how the ambiguity is resolved.
// A recurring 2:30 AM local job needs explicit DST-ambiguity handling
// during the fall-back hour, since 2:30 AM technically occurs twice
function resolveAmbiguousLocalTime(dateStr, timeZone) {
// Most cron-like schedulers built on IANA data pick the first
// occurrence by convention; document this explicitly rather than
// leaving it as an accidental default your team has to rediscover.
return new Date(`${dateStr}T02:30:00`).toLocaleString('en-US', { timeZone });
}
Any recurring job scheduled at a local wall-clock time, rather than a fixed UTC instant, needs an explicit, documented decision about what happens during these two annual edge cases. Most teams never make that decision on purpose; they just inherit whatever their scheduling library happens to do. Standard cron-based schedulers built on the IANA database generally document their own DST-ambiguity resolution explicitly, and it's worth reading that section of your scheduler's documentation once rather than discovering the behavior in production.
Parsing User Input Without Guessing the Format
Free-text date parsing is where a huge share of production date bugs originate, because ambiguous formats like 03/04/2026 mean different things depending on locale (March 4th in the US, April 3rd almost everywhere else).
// Never parse ambiguous slash-delimited dates without an explicit locale
// Bad: silently locale-dependent
new Date('03/04/2026');
// Good: explicit, unambiguous format parsed deliberately
function parseExplicitDate(input, expectedFormat = 'YYYY-MM-DD') {
const match = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) throw new Error(`Expected ${expectedFormat}, got: ${input}`);
const [, year, month, day] = match;
return new Date(Date.UTC(+year, +month - 1, +day));
}
The ISO 8601 standard exists specifically to eliminate this ambiguity, YYYY-MM-DD reads the same regardless of locale. Any form field or API accepting free-text dates should either constrain input to this format with a proper date picker, or validate and reject anything that doesn't match it explicitly rather than trying to guess the user's intended format.
Date Arithmetic Across Month and Year Boundaries
Adding "one month" to a date sounds simple until the date is January 31st, since February 31st doesn't exist. Naive date-math libraries handle this inconsistently, some roll over into March, some clamp to the last day of February.
function addMonthsClamped(date, months) {
const result = new Date(date);
const targetMonth = result.getMonth() + months;
result.setMonth(targetMonth);
// If day-of-month rolled over (e.g. Jan 31 + 1 month), clamp back
if (result.getMonth() !== ((targetMonth % 12) + 12) % 12) {
result.setDate(0); // last day of previous month
}
return result;
}
Decide explicitly which behavior your product needs, clamp or roll over, document it in the function, and cover both January 31st and leap-year February 29th in your test suite. This is one of the highest-value, lowest-effort test cases to add because it catches an entire category of bug with two test lines.
Comparing Dates Across Timezones Without a Common Instant
Comparing two Date objects that were constructed from local-time strings in different timezones, without normalizing to a shared instant first, produces comparisons that look plausible and are wrong.
// Normalize before comparing: convert both to their UTC epoch value
function isBeforeAcrossTimezones(dateAStr, tzA, dateBStr, tzB) {
const instantA = new Date(dateAStr).getTime(); // relies on ISO input with offset
const instantB = new Date(dateBStr).getTime();
return instantA < instantB;
}
The safest guarantee here is refusing to accept a bare local-time string without an explicit UTC offset in the first place. Every function boundary that accepts a date as a string should specify, in its own type signature or documentation, whether it expects an offset-aware ISO string or a plain date, and reject the other.
Testing Date Logic Without Flaky, Time-Dependent Tests
Tests that call new Date() directly and assert against "today" are a common source of test flakiness, especially around midnight in CI runners set to an unexpected timezone. Inject the current time as a parameter, or use a clock-mocking utility, instead of letting test logic depend on the real system clock.
// Testable: time is injected, not read from the global clock
function isExpired(expiryDate, now = new Date()) {
return now.getTime() > expiryDate.getTime();
}
This one change, accepting now as an optional parameter defaulting to the real clock, makes an entire category of date logic deterministically testable, including DST transition dates and leap years, without any mocking library at all.
"Most date bugs I've debugged in production weren't caused by a bad library. They were caused by a local-time string crossing a function boundary that assumed UTC, three layers away from where anyone would think to look." - Dennis Traina, founder of 137Foundry
Where the Native APIs Fall Short
Intl.DateTimeFormat and the built-in Date object cover most of what's above, but arithmetic across timezones, duration calculations, and calendar-aware operations remain awkward with the native API. The Temporal proposal, currently advancing through the JavaScript standards process, is designed specifically to replace Date with an API that treats timezones and calendar arithmetic as first-class concerns rather than an afterthought bolted onto a 1995-era object.
Until Temporal ships broadly, most production codebases still lean on a well-tested library for anything beyond basic formatting, largely because reimplementing correct DST and leap-second handling from scratch is a solved problem not worth re-solving per project.
Leap Seconds and Leap Years Are Two Different Problems
It's worth being precise about a distinction that gets conflated often. Leap years, the extra day added to February every four years with a few century-based exceptions, are handled correctly by essentially every modern date library and the native Date object. Leap seconds, the occasional extra second inserted into UTC to keep it synchronized with Earth's rotation, are a completely different and far messier problem that most application-level code should never try to handle directly. POSIX time, which underlies Date.now() and most system clocks, explicitly ignores leap seconds by design, repeating or skipping a second rather than counting it. For the vast majority of business applications this is invisible and irrelevant; it only matters for systems doing sub-second precision timing against an external reference clock, which is a narrow enough case that it's worth flagging explicitly rather than assuming your date math needs to account for it.
Serializing Durations Separately From Instants
A duration, like "this subscription renews every 30 days," is conceptually different from an instant in time, and conflating the two causes subtle bugs. Storing "30 days" as a fixed number of milliseconds (30 * 24 * 60 * 60 * 1000) breaks the moment a DST transition falls inside that window, since one of those 30 days was actually 23 or 25 hours long in wall-clock terms. Storing durations as calendar units (30 days, 1 month) and resolving them against a specific calendar and timezone at the point of use, rather than pre-computing a fixed millisecond offset, avoids this entire class of error. The MDN documentation on Intl and date handling covers the relevant native APIs for calendar-aware operations in more depth than most teams end up needing, but it's the right first stop before reaching for a third-party library.
Bringing It Together
None of these patterns are exotic. Store and transmit UTC, use named timezone identifiers instead of fixed offsets, make DST ambiguity an explicit decision instead of an accidental default, parse only unambiguous formats, clamp or roll over month arithmetic deliberately, normalize before comparing across timezones, and inject time as a testable parameter. Every one of these is a five-minute fix when caught in code review and a multi-hour incident when it isn't.
If your team is dealing with a codebase where date handling grew organically without these guardrails, our web development team has walked several products through exactly this kind of cleanup, usually surfacing five or six of these patterns already broken somewhere in the existing code. Browse our full services if a broader audit is worth having, or head back to the 137Foundry homepage for more of this kind of practical engineering writeup.