Learn how to format and convert time in JavaScript without surprises: timestamps, ISO strings, time zones, DST, parsing rules, and reliable patterns.

JavaScript time bugs rarely look like “the clock is wrong.” They show up as confusing little shifts: a date that’s correct on your laptop but wrong on a coworker’s machine, an API response that looks fine until it’s rendered in a different timezone, or a report that’s “off by one” around a seasonal time change.
You’ll typically notice one (or more) of these:
+02:00) is different from what you expected.A big source of pain is that the word time can refer to different concepts:
JavaScript’s built-in Date tries to cover all of these, but it primarily represents an instant in time while constantly nudging you toward local display, which makes accidental conversions easy.
This guide is intentionally practical: how to get predictable conversions across browsers and servers, how to choose safer formats (like ISO 8601), and how to spot the classic traps (seconds vs milliseconds, UTC vs local, and parsing differences). The goal isn’t more theory—it’s fewer “why did it shift?” surprises.
JavaScript time bugs often start with mixing representations that look interchangeable, but aren’t.
1) Epoch milliseconds (number)
A plain number like 1735689600000 is typically “milliseconds since 1970-01-01T00:00:00Z”. It represents an instant in time with no formatting or time zone attached.
2) Date object (wrapper around an instant)
A Date stores the same kind of instant as a timestamp. The confusing part: when you print a Date, JavaScript formats it using your environment’s local rules unless you ask otherwise.
3) Formatted string (human display)
Strings like "2025-01-01", "01/01/2025 10:00", or "2025-01-01T00:00:00Z" are not a single thing. Some are unambiguous (ISO 8601 with Z), others depend on locale, and some don’t include a time zone at all.
The same instant can display differently by time zone:
const instant = new Date("2025-01-01T00:00:00Z");
instant.toLocaleString("en-US", { timeZone: "UTC" });
// "1/1/2025, 12:00:00 AM"
instant.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
// "12/31/2024, 4:00:00 PM" (previous day)
Choose a single internal representation (commonly epoch milliseconds or UTC ISO 8601) and stick to it across your app and APIs. Convert to/from Date and formatted strings only at the boundaries: input parsing and UI display.
A “timestamp” usually means epoch time (also called Unix time): the count of time since 1970-01-01 00:00:00 UTC. The catch: different systems count in different units.
JavaScript’s Date is the source of most confusion because it uses milliseconds. Many APIs, databases, and logs use seconds.
17040672001704067200000Same moment, but the millisecond version has three extra digits.
Use explicit multiplication/division so the unit is obvious:
// seconds -> Date
const seconds = 1704067200;
const d1 = new Date(seconds * 1000);
// milliseconds -> Date
const ms = 1704067200000;
const d2 = new Date(ms);
// Date -> seconds
const secondsOut = Math.floor(d2.getTime() / 1000);
// Date -> milliseconds
const msOut = d2.getTime();
Date()This looks reasonable, but it’s wrong when ts is in seconds:
const ts = 1704067200; // seconds
const d = new Date(ts); // WRONG: treated as milliseconds
The result will be a date in 1970, because 1,704,067,200 milliseconds is only about 19 days after the epoch.
When you’re not sure which unit you have, add quick guardrails:
function asDateFromUnknownEpoch(x) {
// crude heuristic: seconds are ~1e9-1e10, milliseconds are ~1e12-1e13
if (x < 1e11) return new Date(x * 1000); // assume seconds
return new Date(x); // assume milliseconds
}
const input = Number(valueFromApi);
console.log({ input, digits: String(Math.trunc(input)).length });
console.log('as ISO:', asDateFromUnknownEpoch(input).toISOString());
If the “digits” count is ~10, it’s probably seconds. If it’s ~13, it’s probably milliseconds. Also print toISOString() while debugging: it’s unambiguous and helps you spot unit mistakes immediately.
JavaScript’s Date can be confusing because it stores a single instant in time, but it can present that instant in different time zones.
Internally, a Date is essentially “milliseconds since the Unix epoch (1970-01-01T00:00:00Z)”. That number represents a moment in UTC. The “shift” happens when you ask JavaScript to format that moment as local time (based on the computer/server settings) versus UTC.
Many Date APIs have both local and UTC variants. They return different numbers for the same instant:
const d = new Date('2025-01-01T00:30:00Z');
d.getHours(); // hour in *local* time zone
d.getUTCHours(); // hour in UTC
d.toString(); // local time string
d.toISOString(); // UTC (always ends with Z)
If your machine is in New York (UTC-5), that UTC time may appear as “19:30” on the previous day locally. On a server set to UTC, it will appear as “00:30”. Same instant, different display.
Logs often use Date#toString() or interpolate a Date implicitly, which uses the environment’s local time zone. That means the same code can print different timestamps on your laptop, in CI, and in production.
Store and transmit time as UTC (e.g., epoch milliseconds or ISO 8601 with Z). Convert to user locale only when displaying:
toISOString() or pass epoch millisecondsIntl.DateTimeFormatIf you’re building an app quickly (for example with a vibe-coding workflow in Koder.ai), it helps to bake this into your generated API contracts early: name fields clearly (createdAtMs, createdAtIso) and keep the server (Go + PostgreSQL) and client (React) consistent on what each field represents.
If you need to send dates/times between a browser, a server, and a database, ISO 8601 strings are the safest default. They’re explicit, widely supported, and (most importantly) they carry time zone information.
Two good interchange formats:
2025-03-04T12:30:00Z2025-03-04T12:30:00+02:00What does “Z” mean?
Z stands for Zulu time, another name for UTC. So 2025-03-04T12:30:00Z is “12:30 at UTC.”
When do offsets like +02:00 matter?
Offsets are crucial when an event is tied to a local timezone context (appointments, bookings, store opening times). 2025-03-04T12:30:00+02:00 describes a moment that is two hours ahead of UTC, and it’s not the same instant as 2025-03-04T12:30:00Z.
Strings like 03/04/2025 are a trap: is it March 4 or April 3? Different users and environments interpret it differently. Prefer 2025-03-04 (ISO date) or a full ISO datetime.
const iso = "2025-03-04T12:30:00Z";
const d = new Date(iso);
const back = d.toISOString();
console.log(iso); // 2025-03-04T12:30:00Z
console.log(back); // 2025-03-04T12:30:00.000Z
That “round-trip” behavior is exactly what you want for APIs: consistent, predictable, and time zone–aware.
Date.parse() feels convenient: hand it a string, get back a timestamp. The problem is that for anything that isn’t clearly ISO 8601, parsing can rely on browser heuristics. Those heuristics have differed across engines and versions, which means the same input may parse differently (or not at all) depending on where your code runs.
Date.parse() can varyJavaScript only standardizes parsing reliably for ISO 8601–style strings (and even then, details like time zone matter). For “friendly” formats—like "03/04/2025", "March 4, 2025", or "2025-3-4"—browsers may interpret:
If you can’t predict the exact string shape, you can’t predict the result.
YYYY-MM-DDA common trap is the plain date form "YYYY-MM-DD" (for example, "2025-01-15"). Many developers expect it to be interpreted as local midnight. In practice, some environments treat this form as UTC midnight.
That difference matters: UTC midnight converted to local time can become the previous day in negative time zones (e.g., Americas) or shift the hour unexpectedly. It’s an easy way to get “why is my date off by one day?” bugs.
For server/API input:
2025-01-15T13:45:00Z or 2025-01-15T13:45:00+02:00."YYYY-MM-DD") and avoid converting it to a Date unless you also define the intended time zone.For user input:
03/04/2025 unless your UI forces the meaning.Instead of relying on Date.parse() to “figure it out,” choose one of these patterns:
new Date(year, monthIndex, day) for local dates).When time data is critical, “it parses on my machine” isn’t good enough—make your parsing rules explicit and consistent.
If your goal is “show a date/time the way people expect,” the best tool in JavaScript is Intl.DateTimeFormat. It uses the user’s locale rules (order, separators, month names) and avoids the brittle approach of manually stitching strings like month + '/' + day.
Manual formatting often hard-codes US-style output, forgets leading zeros, or produces confusing 24/12-hour results. Intl.DateTimeFormat also makes it explicit which time zone you’re displaying—critical when your data is stored in UTC but your UI should reflect the user’s local time.
For “just format it nicely,” dateStyle and timeStyle are the simplest:
const d = new Date('2025-01-05T16:30:00Z');
// User’s locale + user’s local time zone
console.log(new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(d));
// Force a specific time zone (great for event times)
console.log(new Intl.DateTimeFormat('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'UTC'
}).format(d));
If you need consistent hour cycles (e.g., a settings toggle), use hour12:
console.log(new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
}).format(d));
Pick one formatting function per “type” of timestamp in your UI (message time, log entry, event start), and keep the timeZone decision intentional:
This gives you consistent, locale-friendly output without maintaining a fragile set of custom format strings.
Daylight Saving Time (DST) is when a time zone shifts its UTC offset (typically by one hour) on specific dates. The tricky part: DST doesn’t just “change the offset”—it changes the existence of certain local times.
When clocks spring forward, a range of local times never happens. For example, in many regions, the clock jumps from 01:59 to 03:00, so 02:30 local time is “missing.”
When clocks fall back, a range of local times happens twice. For example, 01:30 can occur once before the shift and once after it, meaning the same wall-clock time can refer to two different instants.
These are not equivalent around DST boundaries:
If DST starts tonight, “tomorrow at 9:00 AM” might be only 23 hours away. If DST ends tonight, it might be 25 hours away.
// Scenario: schedule “same local time tomorrow”
const d = new Date(2025, 2, 8, 9, 0); // Mar 8, 9:00 local
const plus24h = new Date(d.getTime() + 24 * 60 * 60 * 1000);
const nextDaySameLocal = new Date(d);
nextDaySameLocal.setDate(d.getDate() + 1);
// Around DST, plus24h and nextDaySameLocal can differ by 1 hour.
setHours can surprise youIf you do something like date.setHours(2, 30, 0, 0) on a “spring forward” day, JavaScript may normalize it to a different valid time (often 03:30), because 02:30 doesn’t exist in local time.
setDate) rather than adding milliseconds.Z so the instant is unambiguous.A common source of bugs is using Date to represent something that isn’t a calendar moment.
A timestamp answers “when did this happen?” (a specific instant like 2025-12-23T10:00:00Z). A duration answers “how long?” (like “3 minutes 12 seconds”). These are different concepts, and mixing them leads to confusing math and unexpected time zone/DST effects.
Date is the wrong tool for durationsDate always represents a point on the timeline relative to an epoch. If you store “90 seconds” as a Date, you’re really storing “1970-01-01 plus 90 seconds” in a particular time zone. Formatting it can suddenly show 01:01:30, shift by an hour, or pick up a date you never intended.
For durations, prefer plain numbers:
HH:mm:ssHere’s a simple formatter that works for countdown timers and media lengths:
function formatHMS(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
formatHMS(75); // "00:01:15" (countdown timer)
formatHMS(5423); // "01:30:23" (media duration)
If you’re converting from minutes, multiply first (minutes * 60) and keep the value numeric until you render it.
When you compare times in JavaScript, the safest approach is to compare numbers, not formatted text. A Date object is essentially a wrapper around a numeric timestamp (epoch milliseconds), so you want comparisons to end up as “number vs number”.
Use getTime() (or Date.valueOf(), which returns the same number) to compare reliably:
const a = new Date('2025-01-10T12:00:00Z');
const b = new Date('2025-01-10T12:00:01Z');
if (a.getTime() < b.getTime()) {
// a is earlier
}
// Also works:
if (+a < +b) {
// unary + calls valueOf()
}
Avoid comparing formatted strings like "1/10/2025, 12:00 PM"—those are locale-dependent and won’t sort correctly. The main exception is ISO 8601 strings in the same format and timezone (e.g., all ...Z), which are lexicographically sortable.
Sorting by time is straightforward if you sort by epoch milliseconds:
items.sort((x, y) => new Date(x.createdAt).getTime() - new Date(y.createdAt).getTime());
Filtering items within a range is the same idea:
const start = new Date('2025-01-01T00:00:00Z').getTime();
const end = new Date('2025-02-01T00:00:00Z').getTime();
const inRange = items.filter(i => {
const t = new Date(i.createdAt).getTime();
return t >= start && t < end;
});
“Start of day” depends on whether you mean local time or UTC:
// Local start/end of day
const d = new Date(2025, 0, 10); // Jan 10 in local time
const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
// UTC start/end of day
const utcStart = new Date(Date.UTC(2025, 0, 10, 0, 0, 0, 0));
const utcEnd = new Date(Date.UTC(2025, 0, 10, 23, 59, 59, 999));
Pick one definition early and stick to it throughout your comparisons and range logic.
Time bugs feel random until you pin down what you have (timestamp? string? Date?) and where the shift is introduced (parsing, time zone conversion, formatting).
Start by logging the same value in three different ways. This quickly reveals whether the issue is seconds vs milliseconds, local vs UTC, or string parsing.
console.log('raw input:', input);
const d = new Date(input);
console.log('toISOString (UTC):', d.toISOString());
console.log('toString (local):', d.toString());
console.log('timezone offset (min):', d.getTimezoneOffset());
What to look for:
toISOString() is wildly off (e.g., year 1970 or far future), suspect seconds vs milliseconds.toISOString() looks right but toString() is “shifted,” you’re seeing a local time zone display issue.getTimezoneOffset() changes depending on the date, you’re crossing daylight saving time.Many “it works on my machine” reports are simply different environment defaults.
console.log(Intl.DateTimeFormat().resolvedOptions());
console.log('TZ:', process.env.TZ);
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
If your server runs in UTC but your laptop runs in a local zone, formatted output will differ unless you specify a timeZone explicitly.
Create unit tests around DST boundaries and “edge” times:
23:30 → 00:30 crossoversIf you’re iterating fast, consider making these tests part of your scaffolding. For example, when generating a React + Go app in Koder.ai, you can add a small “time contract” test suite up front (API payload examples + parsing/formatting assertions) so regressions get caught before deployment.
"2025-03-02 10:00".locale and (when needed) timeZone.Reliable time handling in JavaScript is mostly about choosing a “source of truth” and being consistent from storage to display.
Store and compute in UTC. Treat user-facing local time as a presentation detail.
Transmit dates between systems as ISO 8601 strings with an explicit offset (preferably Z). If you must send numeric epochs, document the unit and keep it consistent (milliseconds is the common default in JS).
Format for humans with Intl.DateTimeFormat (or toLocaleString), and pass an explicit timeZone when you need deterministic output (for example, always showing times in UTC or a specific business region).
Z (e.g., 2025-12-23T10:15:00Z). If using epochs, include a field name like createdAtMs to make units obvious.Consider a dedicated date-time library if you need recurring events, complex time zone rules, DST-safe arithmetic (“same local time tomorrow”), or lots of parsing from inconsistent inputs. The value is in clearer APIs and fewer edge-case bugs.
If you want to go deeper, browse more time-related guides at /blog. If you’re evaluating tooling or support options, see /pricing.