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

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.

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'

Full Production Version

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.

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'");

Common Pitfalls

// 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

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.