How to Convert Local Time to UTC in JavaScript

To convert local time to UTC, attach the originating IANA timezone to the wall-clock value and take its absolute instant β€” Temporal.PlainDateTime.from(local).toZonedDateTime(tz).toInstant().toString() β€” or, if you already hold a correct Date, just call toISOString(). Part of Understanding UTC vs Local Time in JS.

Why This Scenario Is Tricky

Converting local time to UTC is tricky because the phrase "local time" is under-specified in a way that determines the answer. A wall-clock reading like "March 15, 9:00 AM" is not a moment until you say which zone's 9 AM it is β€” the same reading is a different UTC instant in New York than in Berlin. So a correct conversion needs three inputs, not one: the wall-clock value, the source zone it was read in, and then the target (UTC). The common bug is to skip the source-zone input and let the runtime assume the host zone, which silently makes the conversion depend on where the code happens to run.

The one case that is genuinely unambiguous is a string that already carries an explicit offset β€” 2024-03-15T12:00:00-05:00 β€” because the offset is the source-zone information, so new Date() can parse it to the correct instant and toISOString() serializes that instant in UTC without guessing. The moment the offset is missing, you are back to needing an explicit source zone. Recognizing which of these two situations you are in β€” offset present or absent β€” is the key to converting correctly.

A wall-clock string like 2024-03-10T02:30:00 is ambiguous on its own: it names a clock face, not a moment. The same digits mean different instants in New York, London, and Tokyo, so converting "local to UTC" is impossible without knowing which local zone produced it. The first failure mode is forgetting that the zone is required input, not something the runtime can infer correctly for arbitrary historical or future dates.

The second failure mode is reaching for getTimezoneOffset() and doing the subtraction by hand. That method returns the offset of the host's current local time β€” not the offset that applied on the date being processed. Serialise a January timestamp using an offset captured in July and you bake in a one-hour error across every DST region. Worse, some wall-clock values are non-existent (the spring-forward gap) or doubled (the fall-back overlap), and manual arithmetic has no way to represent either. The correct tools push the offset lookup and the gap/overlap decision down into the timezone database where they belong.

Local to UTC needs a zoneA wall-clock value plus an IANA zone resolves to a UTC instant; DST gaps are ambiguousLocal wall time needs a zone to become an instant'2024-03-10 02:30'wall-clock fields+ IANA zoneresolve offset(DST-aware)UTC instantAt a spring-forward gap the wall time is ambiguous β†’ pick a disambiguation.

Minimal Working Solution

If the value already lives in a correctly constructed Date, the conversion is free β€” Date stores UTC internally, and toISOString() exposes it directly with no offset math.

// An ISO string WITH an explicit offset is unambiguous, so new Date() is safe here.
const date = new Date('2024-03-15T12:00:00-05:00');
// toISOString() serialises the stored UTC value β€” never the host zone.
console.log(date.toISOString()); // '2024-03-15T17:00:00.000Z'

The catch is that this only works when the Date is already correct. A bare wall-clock string with no offset ('2024-03-10T02:30:00') is parsed against the host zone, which is exactly the assumption you are trying to eliminate. For that case, name the zone explicitly with Temporal.

import { Temporal } from '@js-temporal/polyfill';

const local = Temporal.PlainDateTime.from('2024-03-10T02:30:00');
// 'later' resolves the spring-forward GAP to the post-transition instant (03:30 EDT).
const utc = local.toZonedDateTime('America/New_York', { disambiguation: 'later' })
  .toInstant().toString();
console.log(utc); // '2024-03-10T07:30:00Z'

PlainDateTime β†’ ZonedDateTime β†’ InstantAttach the zone, then take the instantPlainDateTime β†’ ZonedDateTime β†’ InstantPlainDateTimefrom(local).toZonedDateTime(zone).toInstant()β†’ UTC

Full Production Version

With Temporal the conversion is a two-step pipeline that keeps the source zone explicit: interpret the wall-clock value in its source zone with PlainDateTime.from(wall).toZonedDateTime(sourceZone), then read the UTC instant with .toInstant() (or convert to the UTC zone). Because the source zone is a named argument, there is no hidden dependence on the host, and the conversion produces the same result everywhere. If the input already has an offset, Temporal.Instant.from(string) parses it directly, since the offset supplies the zone information the conversion needs.

The subtle part is transition handling. A wall-clock time in the source zone can be ambiguous (the repeated hour at fall-back) or nonexistent (the skipped hour at spring-forward), and toZonedDateTime takes a disambiguation option to resolve it deliberately rather than guessing. For most conversions the default is fine, but a system converting user-entered times near a transition should choose a policy. Once you have the instant, serializing to UTC is exact β€” toISOString() on a Date, or the canonical Z string on a Temporal Instant β€” because an instant is zone-independent. The rule to internalize is: a wall time plus its source zone yields an instant; an instant serializes to UTC unambiguously; never let the source zone be implicit.

A reusable converter takes the wall-clock string and its IANA zone as explicit inputs, validates both, and surfaces the disambiguation policy to the caller rather than guessing.

import { Temporal } from '@js-temporal/polyfill';

type Disambiguation = 'earlier' | 'later' | 'compatible' | 'reject';

/**
 * Convert a zone-less local datetime string to a UTC ISO 8601 string.
 * @param localDateTime - wall clock without offset, e.g. '2024-03-10T02:30:00'
 * @param timeZone      - IANA id the value was captured in, e.g. 'America/New_York'
 * @param disambiguation - how to resolve DST gaps/overlaps; 'reject' fails loud
 */
function localToUTC(
  localDateTime: string,
  timeZone: string,
  disambiguation: Disambiguation = 'compatible',
): string {
  let plain: Temporal.PlainDateTime;
  try {
    // from() rejects malformed input AND any string carrying an offset/zone,
    // forcing callers to pass a genuine wall-clock value.
    plain = Temporal.PlainDateTime.from(localDateTime, { overflow: 'reject' });
  } catch {
    throw new TypeError(`Invalid local datetime: ${localDateTime}`);
  }
  try {
    // Constructing the ZonedDateTime validates the IANA id and applies DST rules.
    return plain.toZonedDateTime(timeZone, { disambiguation }).toInstant().toString();
  } catch (err) {
    // With 'reject', a gap or overlap throws RangeError β€” re-surface it to the caller.
    throw new RangeError(`Cannot resolve ${localDateTime} in ${timeZone}: ${String(err)}`);
  }
}

console.log(localToUTC('2024-07-01T09:00:00', 'America/New_York'));        // '2024-07-01T13:00:00Z' (EDT, UTC-4)
console.log(localToUTC('2024-01-01T09:00:00', 'America/New_York'));        // '2024-01-01T14:00:00Z' (EST, UTC-5)
console.log(localToUTC('2024-03-10T02:30:00', 'America/New_York', 'later')); // '2024-03-10T07:30:00Z'

The January and July results differ by an hour for the same 09:00 wall time β€” proof that the offset must come from the date plus the zone, never from a cached getTimezoneOffset(). The canonical end-to-end pattern: capture local input, read the user's zone via Intl.DateTimeFormat().resolvedOptions().timeZone, convert to a UTC instant, send the ISO string, store it as-is, and convert back to local only at display time.

Validated conversion with disambiguationValidate the zone and choose earlier/later/compatible/reject for gap timesValidated conversion with disambiguationlocal + zonevalidate zonedisambiguationpolicyInstant /epoch ms

Verification Snippet

import { Temporal } from '@js-temporal/polyfill';

// Same wall time, opposite sides of the DST line -> different UTC instants.
console.assert(localToUTC('2024-01-01T09:00:00', 'America/New_York') === '2024-01-01T14:00:00Z', 'EST offset');
console.assert(localToUTC('2024-07-01T09:00:00', 'America/New_York') === '2024-07-01T13:00:00Z', 'EDT offset');

// A correct Date round-trips through toISOString without offset math.
const d = new Date('2024-03-15T12:00:00-05:00');
console.assert(d.toISOString() === '2024-03-15T17:00:00.000Z', 'Date stores UTC');

// The spring-forward gap is non-existent: 'reject' must throw.
let threw = false;
try { localToUTC('2024-03-10T02:30:00', 'America/New_York', 'reject'); } catch { threw = true; }
console.assert(threw, "gap time must be rejected under 'reject'");

Local→UTC assertionsA normal time and a gap time both resolve correctlyAssertions that prove the edge casetoUTC('2024-01-15 09:00','NY')14:00Zgap 02:30 with 'later'03:30 → 07:00Zgap 02:30 with 'reject'throwsround-trip backsame wall time

Common Pitfalls

The dominant pitfall is converting a bare wall-clock value without specifying its source zone, so the runtime assumes the host zone and the result changes depending on where the code runs. Always supply the source zone explicitly. The second is assuming new Date('2024-03-15T09:00') (no offset) is UTC β€” it is parsed as local time in most environments, so the resulting instant is host-dependent; add an explicit offset or parse with an explicit zone. The third is ignoring transition ambiguity, letting an impossible or doubled wall time resolve silently; pass a disambiguation option when the input can land near a DST change.

A fourth pitfall is hand-computing UTC by adding the offset yourself, which is error-prone (the sign is easy to invert) and wrong when the offset differs by date due to DST; let the zone-aware conversion apply the correct offset for that specific instant. A fifth is storing the converted UTC instant but discarding the original source zone, when the application later needs to show the time back in the user's local clock β€” if you need the wall-clock intent preserved, store the full ZonedDateTime, not just the UTC instant. Keeping the source zone explicit throughout is the single habit that prevents this whole family of bugs.

// Wrong: bakes the host's current offset into every value.
const wrong = new Date(local.getTime() - local.getTimezoneOffset() * 60000).toISOString();
// Right: the Date already holds UTC β€” read it directly.
const right = local.toISOString();
const wrong = new Date('2024-03-10T02:30:00').toISOString();          // host-dependent!
const right = localToUTC('2024-03-10T02:30:00', 'America/New_York');  // zone is explicit

Local→UTC pitfallsParsing local strings with new Date uses the host zone, not the intended oneWrongRightnew Date('2024-03-10 02:30')host zone, not targetPlainDateTime.toZonedDateTime(zone)target zone, DST-awareconcatenate a fixed offsetbreaks across DSTlet the zone db resolve offsetcorrect year-round

Frequently Asked Questions

Does Date.toISOString() convert local time to UTC?

It does not convert anything β€” it serialises the UTC value the Date already stores. As long as the Date was constructed correctly (from an ISO string with an offset, or from explicit Date.UTC parts), toISOString() is the right, math-free way to get UTC.

Do I really need the IANA timezone to convert local time to UTC?

Yes. A wall-clock value names a clock face, and the same face maps to different instants in different zones, with the offset varying by date because of DST. Without the originating IANA id the conversion is undefined; never substitute a fixed numeric offset for storage.

How do I handle the fall-back hour that repeats?

Use the disambiguation option on Temporal.PlainDateTime.toZonedDateTime: 'earlier' picks the pre-transition instant, 'later' the post-transition one, 'reject' throws so you can ask the user. The doubled local hour maps to two real UTC instants, so you must choose one explicitly.

How do I convert a local time to UTC in JavaScript?

Interpret the wall-clock value in its source zone, then read the instant: Temporal.PlainDateTime.from(wall).toZonedDateTime(sourceZone).toInstant(), and serialize with the canonical Z string. The source zone must be explicit, because a wall-clock reading is a different UTC instant in different zones. If the input string already has an offset, Temporal.Instant.from parses it directly, since the offset supplies the zone information.

Why is new Date('2024-03-15T09:00') not reliably UTC?

Because a bare date-time string without an offset is parsed as local time in most environments, so the resulting instant depends on the host machine's zone. Only strings with an explicit Z or Β±HH:MM offset parse to a fixed instant regardless of host. To convert a local wall time to UTC, supply the source zone explicitly rather than relying on the runtime's assumption.

How do I handle a local time that falls in a daylight-saving gap when converting to UTC?

Pass a disambiguation option to toZonedDateTime: 'compatible' (default) picks a sensible instant, 'earlier'/'later' choose a side of a fall-back overlap, and 'reject' throws so you can handle it. A spring-forward gap has no valid wall time and a fall-back hour has two, so a system converting user-entered times near a transition should set this policy deliberately instead of accepting a silent guess.