Use Intl.DateTimeFormat to format dates into any IANA timezone for display, and reach for Temporal or a timezone-aware library like date-fns-tz or Luxon when you need to do arithmetic across zones. The rule underneath both: store and transmit timestamps in UTC, usually as ISO 8601 strings, and convert only at the moment you render them to a reader.
TL;DR:
- Use named IANA zones such as America/New_York rather than fixed offsets, because named zones follow historical and future daylight saving rules.
- For repeated formatting across large tables or exports, create one Intl.DateTimeFormat instance and reuse it instead of rebuilding it for every date.
- Use Temporal or a timezone aware library for date arithmetic, since adding 24 hours can shift the local time across daylight saving transitions.
- Temporal requires an explicit choice when a fall transition repeats a wall clock time: select the earlier or later time, or reject the ambiguity.
- Test locale formatting in the deployment environment, because Node.js builds with limited ICU data may produce incomplete results for non English locales.
Table of Contents
- How to format a date in another IANA timezone with Intl.DateTimeFormat
- JavaScript Date internals and getTimezoneOffset explained
- Using toLocaleString and formatToParts for quick formatting and custom templates
- Libraries and patterns for timezone-aware arithmetic: date-fns-tz and Luxon examples
- Temporal API (ZonedDateTime): what it fixes and how to start adopting it
- Server-side and Node.js caveats: ICU and environment differences
- Common pitfalls and worked examples
- Practical debugging tips and tools that save time
- Author perspective: team rules and trade-offs for production systems
- Try the Pingfloat time zone tools while you build
- FAQ
- Sources
How to format a date in another IANA timezone with Intl.DateTimeFormat
Intl.DateTimeFormat takes a timeZone option that accepts standard IANA identifiers such as America/New_York or Europe/London, and it produces output that correctly accounts for daylight saving time for that specific instant. This is the standard, dependable way to show a date in a zone other than the host machine's, according to MDN's reference for the constructor, and it works the same whether the code runs in a browser or on a server.
Here is a basic example that formats a single UTC instant into two different zones:
const instant = new Date('2026-03-08T12:00:00Z');
const nyFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
dateStyle: 'medium',
timeStyle: 'short',
});
const londonFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/London',
dateStyle: 'medium',
timeStyle: 'short',
});
console.log(nyFormatter.format(instant));
console.log(londonFormatter.format(instant));
Adding timeZoneName: 'short' to the options object appends the zone abbreviation (EST, GMT, and so on) directly to the output, which is useful when you are showing a time to a reader who might not know which zone your app defaulted to.
When you need structured data instead of a formatted string, formatToParts() breaks the result into its components, a pattern the MDN examples cover directly:
const parts = nyFormatter.formatToParts(instant);
const lookup = Object.fromEntries(parts.map(p => [p.type, p.value]));
console.log(lookup.hour, lookup.minute, lookup.dayPeriod);
This is the approach to reach for when you are building a custom template, such as a table column that needs the hour and minute in separate cells, rather than a single preformatted string.
One performance detail matters once you move past a handful of calls: constructing an Intl.DateTimeFormat instance has real overhead, so if you are formatting hundreds or thousands of dates (a log table, a report, a CSV export) build the formatter once outside the loop and call .format() repeatedly on it, instead of creating a new instance per row.
Named IANA zones also beat raw offsets like +02:00 for anything that needs to work correctly over time, because an IANA zone encodes the historical and future DST rules for that region, while a fixed offset does not adjust itself.

JavaScript Date internals and getTimezoneOffset explained
A JavaScript Date object stores a single number representing milliseconds elapsed since the start of the epoch on January 1, 1970, UTC, as described by MDN's Date reference. That number is always UTC under the hood. Every "local" getter, getHours(), getDate(), getDay(), interprets that same UTC millisecond value through the lens of whatever timezone the JavaScript engine thinks the host machine is in.
getTimezoneOffset() returns the difference, in minutes, between UTC and the host's local time for the specific instant represented by the Date object, which can vary across daylight saving time transitions, so offsets for dates months apart can differ for the same host zone. This is exactly why manual offset math tends to break: a script that subtracts a fixed number of minutes to "convert" a time will silently produce the wrong answer the moment the calculation crosses a DST transition.
const beforeDst = new Date('2026-03-07T12:00:00Z');
const afterDst = new Date('2026-03-09T12:00:00Z');
console.log(beforeDst.getTimezoneOffset());
console.log(afterDst.getTimezoneOffset());
Run that in a host configured for America/New_York and the two offsets differ by 60 minutes, because the United States shifts to daylight saving time in between those two dates. Any code that hard-codes an offset instead of asking the runtime for the current one at that instant inherits this bug.
Using toLocaleString and formatToParts for quick formatting and custom templates
toLocaleString() is the shorthand version of the same formatting logic: call it directly on a Date instance with a locale and an options object, and skip creating a separate formatter object.
new Date().toLocaleString('en-US', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
It is convenient for a one-off call, but it recreates the underlying formatter internally every time you invoke it, so for repeated formatting of many dates, an explicit Intl.DateTimeFormat instance you reuse is the better choice.
formatToParts() remains the right tool when you need the individual pieces (year, month, day, hour, dayPeriod) rather than a finished string, for instance when assembling a custom date template that does not match any built-in dateStyle.
Two small pitfalls show up often: hour12 defaults differ by locale, so an explicit hour12: true or false avoids surprises, and some locales omit a space or punctuation mark you might expect, which only formatToParts() lets you control precisely.
Libraries and patterns for timezone-aware arithmetic: date-fns-tz and Luxon examples
Formatting is only half the problem. Arithmetic, adding a day, finding "9 AM tomorrow in Tokyo", comparing two zoned times, needs to happen in wall-clock terms for a specific zone, not in raw UTC milliseconds, because adding 24 hours of milliseconds does not always land on the same wall-clock hour across a DST transition.
date-fns-tz extends date-fns with zone-aware helpers:
import { toZonedTime, fromZonedTime, formatInTimeZone } from 'date-fns-tz';
import { addDays } from 'date-fns';
const utcInstant = new Date('2026-03-08T12:00:00Z');
const zoned = toZonedTime(utcInstant, 'America/New_York');
const zonedPlusOneDay = addDays(zoned, 1);
const backToUtc = fromZonedTime(zonedPlusOneDay, 'America/New_York');
console.log(formatInTimeZone(backToUtc, 'America/New_York', 'yyyy-MM-dd HH:mm zzz'));
Luxon takes a similar approach with a single chainable object:
import { DateTime } from 'luxon';
const dt = DateTime.fromISO('2026-03-08T12:00:00Z', { zone: 'utc' })
.setZone('America/New_York')
.plus({ days: 1 });
console.log(dt.toUTC().toISO());
Both libraries do the same core job: convert a UTC instant into zone-local wall-clock terms, perform the arithmetic there so DST rules apply correctly, then convert back to UTC for storage or transmission.
A few guidelines keep this pattern from turning into a mess of conversions scattered through a codebase:
- Treat UTC as the only canonical representation you store in a database or send over an API.
- Convert to a zone only at the point where domain logic genuinely needs wall-clock time, such as "did this invoice post before midnight in the customer's zone."
- Convert back to UTC immediately after the zoned calculation finishes, rather than passing zoned objects further through the system.
Temporal API (ZonedDateTime): what it fixes and how to start adopting it
Temporal introduces Temporal.ZonedDateTime, which pairs an exact instant with an IANA timezone identifier and a calendar, according to the TC39 Temporal proposal documentation. That pairing is the structural fix for the problem Date never solved: a Date is just a number, so any notion of "timezone" has to be bolted on by the developer, while a ZonedDateTime carries its zone with it everywhere it goes.
const zdt = Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2026,
month: 11,
day: 1,
hour: 1,
minute: 30,
});
const instant = zdt.toInstant();
The hour: 1, minute: 30 value above is ambiguous on a fall DST transition date, since that wall-clock time occurs twice. Temporal exposes disambiguation options ('earlier', 'later', 'reject', 'compatible') specifically for this case, letting you decide explicitly rather than inheriting whatever the runtime guesses.
Temporal models a timezone as a function mapping an instant to a wall-clock representation, not as a fixed offset, which is the mental model that trips up most offset-math bugs in the first place.
For new projects targeting modern runtimes, Temporal is worth adopting directly. For code that has to run on older engines today, a maintained library like Luxon or date-fns-tz remains the practical choice, with a Temporal polyfill as an option for projects that want to start writing Temporal-shaped code ahead of native support.
Pro Tip: Write a thin wrapper around your date library calls now, so swapping in native Temporal later touches one file instead of every call site.

Server-side and Node.js caveats: ICU and environment differences
Intl.DateTimeFormat depends on ICU (International Components for Unicode) data bundled with the JavaScript runtime, and Node.js can ship with different ICU builds: system-icu, small-icu, or full-icu, according to the Node.js Intl documentation. A small-icu build often only has full locale data for English, which can mean missing or incorrect timezone names and locale formatting for other languages, even though the timeZone option itself still works for the UTC offset calculation.
This matters because code that formats correctly on your laptop can format differently, or throw, on a production container built from a slimmer Node image.
- Check your runtime's ICU level before deploying by inspecting
process.releaseor by testing a non-English locale format call directly. - Test formatting output in the actual deployment environment, not just locally, since Docker base images frequently default to smaller ICU builds.
- Ship
full-icuor configure theNODE_ICU_DATAenvironment variable when your application needs complete locale and timezone name coverage.
Common pitfalls and worked examples
A few patterns account for most of the timezone bugs that reach production.
Manual offset subtraction near a DST boundary is the most common one:
// Wrong: assumes a fixed 5-hour offset year-round
const wrong = new Date(utcDate.getTime() - 5 * 60 * 60 * 1000);
That math is correct for roughly half the year in America/New_York and wrong for the other half, because the real offset flips between 4 and 5 hours with DST.
Constructing a Date from local-looking string components and assuming it is UTC is the second common mistake. new Date(2026, 2, 8, 9, 0) builds a date in the host's local zone, not UTC, so passing that value to a server expecting UTC silently shifts every downstream calculation.
Scheduling "every day at midnight" across a DST-observing zone is the third. A scheduler that adds 24 * 60 * 60 * 1000 milliseconds to find "tomorrow midnight" lands on 11 PM or 1 AM on the two DST transition days each year. date-fns-tz's addDays on a zoned value, or Temporal's ZonedDateTime.add({ days: 1 }), both respect the wall-clock day boundary instead of a fixed millisecond count.
A short production checklist catches most of this before it ships:
- Store and transmit every timestamp as a UTC instant, preferably ISO 8601.
- Convert to a reader's IANA zone only at the formatting or presentation layer.
- Do zone-aware arithmetic with Temporal or a vetted library, never with manual offset math.
- Add disambiguation handling or an equivalent check for any code that parses user-entered wall-clock times near a DST transition.
- Run your formatting tests against several zones, including at least one DST boundary date, in the actual deployment environment.
Practical debugging tips and tools that save time
A reliable way to reproduce a timezone bug is to pin the exact failing instant and zone, then rerun formatting against both locally and in CI with that same pair of values rather than "today." Logging the UTC epoch alongside the user's IANA timezone string, instead of a pre-formatted local string, gives you everything needed to replay the bug later.
The Time Zone Converter is a quick way to cross-check a conversion by hand against what your code produces, especially for an unfamiliar IANA zone. Pairing it with the Unix Timestamp & Epoch Converter lets you go from a raw epoch value in a log line straight to a readable date, which speeds up tracing a bug reported only as a timestamp.
Author perspective: team rules and trade-offs for production systems
The rule that has saved the most debugging time on every team I have worked with: store UTC, format late, and let Temporal or a vetted library own the zone math, never hand-rolled offsets. Native Intl covers display needs for most projects; pull in a library once your domain logic genuinely depends on zoned scheduling or recurring events. Whatever you choose, run formatting tests against a handful of zones that include at least one DST boundary date, since that is where untested code breaks first.
— Andreas
Try the Pingfloat time zone tools while you build

Verifying a conversion by hand takes thirty seconds with the right tool instead of a console full of Date debugging. Our Time Zone Converter shows IANA zone names and DST-aware results side by side, and our Unix Timestamp & Epoch Converter turns a raw epoch value from a log line into a readable date in seconds. Both tools are free to use, right from the Pingfloat homepage, with no account needed for core functionality.
FAQ
How do I get the time zone in JavaScript?
The host environment's default zone is available through Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the IANA identifier the runtime is using. To work with a specific zone regardless of the host, pass that IANA name directly into a new Intl.DateTimeFormat call's timeZone option instead of relying on the default.
How do I convert UTC time to local time in JavaScript?
Pass your UTC Date or ISO string into Intl.DateTimeFormat or toLocaleString() with the target IANA zone set in the timeZone option, and the formatter produces the correct local wall-clock string. For arithmetic rather than display, convert the UTC instant into a zoned value with Temporal's ZonedDateTime, Luxon's setZone, or date-fns-tz's toZonedTime first.
What is the formula for converting between time zones?
There is no single fixed formula, because the difference between two zones changes across DST transitions throughout the year. The reliable approach is to let the runtime resolve the correct offset for a specific instant through an IANA-zone-aware API like Intl.DateTimeFormat, Temporal, or a maintained library, rather than subtracting a hardcoded number of hours.
How do I convert a UTC time to a different time zone?
Construct your value as a UTC instant, then hand it to Intl.DateTimeFormat with the destination IANA zone in the timeZone option for display, or to Temporal.ZonedDateTime, Luxon, or date-fns-tz when you need to do calculations in that zone before converting back to UTC. Always use a named IANA zone such as Asia/Tokyo rather than a fixed offset, since named zones track DST and historical rule changes automatically.
