Locale-Sensitive Date Comparison and Sorting

This guide explains how to order, compare, and deduplicate dates correctly across timezones and locales. Part of Intl API & Legacy Date Patterns.

Problem framing

The single most common date-sorting bug is comparing the displayed string instead of the underlying instant. A list rendered as 15/03/2024, 01/04/2024, 28/02/2024 sorted lexicographically puts 01/04 before 15/03 before 28/02 — alphabetical order on day-first text has nothing to do with chronology. Switch the locale to en-US (03/15/2024) and the "sorted" order changes again, even though the underlying moments never moved. Formatted text is a presentation artifact: month names, separators, digit systems, and field order all vary by locale, so any ordering derived from it is non-deterministic. Chronological order is a property of the absolute instant — the epoch value — and must be computed from numbers, never from glyphs.

The one rule, visualized

Sort by the absolute instant (epoch milliseconds, or a Temporal compare result). Use locale only at the final render step.

Sort by formatted string (wrong) vs sort by epoch (right)Two pipelines: the left sorts localized strings and produces a scrambled order; the right sorts epoch numbers and produces correct chronological order, then formats for display.Sort by formatted stringSort by epoch / instantformat() first, then compare text"15/03" "01/04" "28/02"localeCompare on glyphs"01/04" "15/03" "28/02"wrong orderlocale change reshuffles resultnon-deterministicread epoch / instant1709... 1711... 1709...numeric / Temporal.compareFeb 28 Mar 15 Apr 01correct orderformat() last, for display onlylocale-stable order

API reference

Type / method Returns Comparison basis Timezone caveat
Date.prototype.getTime() number (epoch ms) Absolute instant None — already UTC-based
Number(date) / +date number (epoch ms) Absolute instant Same as getTime()
Temporal.Instant.compare(a, b) -1 | 0 | 1 Absolute instant (epoch ns) Zone-agnostic by design
Temporal.ZonedDateTime.compare(a, b) -1 | 0 | 1 Absolute instant Compares instants, ignores zone label
Temporal.PlainDate.compare(a, b) -1 | 0 | 1 Calendar date only No instant — wall-clock semantics
Temporal.PlainDateTime.compare(a, b) -1 | 0 | 1 Wall-clock date+time No zone; equal local times tie
zdt.equals(other) boolean Instant and zone+calendar Stricter than compare(...) === 0
Intl.Collator comparator Locale text order For names/labels — never for chronology

compare static methods return -1, 0, or 1, which is exactly the contract Array.prototype.sort expects, so they drop straight into a sort callback. getTime() returns a plain number, so subtraction gives the same three-way signal.

Approach A: legacy Date

Date already stores a UTC epoch internally, so ordering is just numeric comparison. Subtract the epoch values — do not subtract Date objects without coercion and never sort their string forms.

const isoInputs = ['2024-03-15T00:00:00Z', '2024-04-01T00:00:00Z', '2024-02-28T00:00:00Z'];

// Parse to Date, then sort by epoch milliseconds (absolute instant).
const sorted = isoInputs
  .map((s) => new Date(s))
  .sort((a, b) => a.getTime() - b.getTime()); // numeric: negative => a before b

console.log(sorted.map((d) => d.toISOString()));
// ['2024-02-28...', '2024-03-15...', '2024-04-01...'] — correct, locale-independent

Equality with Date must also use the number: a.getTime() === b.getTime(), never a === b (reference equality, always false for distinct objects) and never String(a) === String(b) (the string includes the host timezone, so two equal instants in different runs can render differently). Limitations of Date here: it has only a single instant axis, so it cannot express "same calendar day, ignoring time" without manual truncation, and parsing non-ISO or offset-less strings is implementation-defined.

Approach B: Temporal / Intl

Temporal gives a distinct type — and a matching compare — for each comparison question, so you pick the axis explicitly instead of hoping Date guesses right.

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

// Absolute-instant ordering across mixed zones: compare the moment, not the label.
const instants = [
  Temporal.Instant.from('2024-03-15T12:00:00Z'),
  Temporal.Instant.from('2024-03-15T08:00:00-05:00'), // 13:00Z — later instant
];
instants.sort(Temporal.Instant.compare); // static method is already a (-1|0|1) comparator

// Calendar-date ordering, no time, no zone — e.g. sorting birthdays.
const dates = [
  Temporal.PlainDate.from('2024-04-01'),
  Temporal.PlainDate.from('2024-02-28'),
];
dates.sort(Temporal.PlainDate.compare);

// Zoned events: compare() ranks by instant; .equals() additionally checks zone+calendar.
const a = Temporal.ZonedDateTime.from('2024-11-01T12:00-04:00[America/New_York]');
const b = Temporal.ZonedDateTime.from('2024-11-01T17:00+01:00[Europe/London]');
console.log(Temporal.ZonedDateTime.compare(a, b)); // 0 — same UTC instant
console.log(a.equals(b)); // false — same instant but different zone identity

The split between compare and equals is the crux: compare(a, b) === 0 means "same instant"; a.equals(b) means "same instant and same zone and calendar". Use compare for sorting and chronological ordering; use equals for deduplication keyed on full identity. For the zoned-comparison rules in depth, see comparing ZonedDateTime across timezones.

Production implementation

A reusable sorter that accepts Date, ISO strings, or Temporal types, validates input, and always orders by the absolute instant. Display formatting is deliberately kept out of this layer.

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

type Sortable = Date | string | Temporal.Instant | Temporal.ZonedDateTime;

// Normalize every supported input to epoch nanoseconds (BigInt) — the absolute instant.
function toEpochNs(value: Sortable): bigint {
  if (value instanceof Temporal.Instant) return value.epochNanoseconds;
  if (value instanceof Temporal.ZonedDateTime) return value.epochNanoseconds;
  if (value instanceof Date) {
    const ms = value.getTime();
    if (Number.isNaN(ms)) throw new RangeError('Invalid Date');
    return BigInt(ms) * 1_000_000n; // ms -> ns
  }
  if (typeof value === 'string') {
    // Instant.from handles ISO strings with an offset or trailing Z.
    return Temporal.Instant.from(value).epochNanoseconds;
  }
  throw new TypeError(`Unsupported value: ${String(value)}`);
}

export function sortChronologically<T extends Sortable>(items: readonly T[]): T[] {
  // Decorate-sort-undecorate avoids recomputing epoch on every comparator call.
  return items
    .map((item) => ({ item, key: toEpochNs(item) }))
    .sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0)) // BigInt: no subtraction overflow
    .map((entry) => entry.item);
}

SSR / serverless note: this utility computes order from epoch values only, so it produces identical results on a server in UTC, a server in America/New_York, and a browser in Asia/Tokyo. That host-zone independence is exactly what prevents hydration mismatches — the server-rendered order and the client-rehydrated order can never diverge because neither reads the host timezone. Apply Intl.DateTimeFormat with an explicit timeZone only when rendering, after sorting.

Edge cases

Same instant, different zone labels

compare treats 12:00-04:00[America/New_York] and 17:00+01:00[Europe/London] as equal (0) because they are the same UTC moment. If your sort must be stable for such ties — e.g. group by zone within equal instants — add a secondary key on timeZoneId. Do not assume a tie means the rows are interchangeable.

Comparing wall-clock dates across zones

"Which events fall on the same local calendar day?" is a PlainDate question, not an instant question. Convert each ZonedDateTime to its local date with .toPlainDate() before comparing, otherwise an event at 23:30 in Tokyo and 09:30 in New York can share an instant window yet belong to different local days. Decide up front whether the business rule is instant ordering or local-day grouping.

Offset-less ISO strings

Temporal.Instant.from('2024-03-15T12:00:00') throws because there is no offset to anchor the instant. Either require a Z/offset suffix, or interpret the string in a known zone via Temporal.PlainDateTime.from(...).toZonedDateTime(tz, { disambiguation: 'compatible' }) first. The 'compatible' policy mirrors legacy Date behavior during DST gaps and overlaps.

DST fall-back overlap

During fall-back, a local time like 01:30 occurs twice. If you sort PlainDateTime values, the two occurrences tie even though they are an hour apart in reality. Resolve to ZonedDateTime (choosing disambiguation: 'earlier' or 'later') before ordering when the real-world sequence matters.

Gotchas & common pitfalls

Testing checklist

Scenario Input Expected order (first → last)
Plain ISO UTC strings ['2024-03-15Z','2024-04-01Z','2024-02-28Z'] Feb 28, Mar 15, Apr 01
Mixed offsets, same wall time ['2024-01-01T00:00-05:00','2024-01-01T00:00+09:00'] +09:00 entry first (earlier instant)
Same instant, different zones NY 12:00-04:00, London 17:00+01:00 tie (compare === 0)
Locale flip does not reorder sort under de-DE, then en-US identical order both times
Invalid input new Date('nope') throws RangeError
# Run the suite under several host zones to prove order is zone-independent.
for TZ in UTC America/New_York Asia/Kolkata Pacific/Auckland; do
  TZ=$TZ npm test -- compare-sort
done

FAQ

Frequently Asked Questions

Why does sorting my dates give a different order on different machines?

You are almost certainly sorting a string form (toString, toLocaleString, or an Intl.DateTimeFormat output). Those depend on the host timezone and locale. Sort by date.getTime() or Temporal.Instant.compare, which read the absolute instant, and the order becomes identical everywhere.

What is the difference between compare and equals in Temporal?

compare(a, b) returns -1, 0, or 1 based purely on the absolute instant — use it for sorting. a.equals(b) returns a boolean that is true only when the instant and the timezone and calendar match. Two equal instants in different zones give compare === 0 but equals === false.

How do I sort birthdays or calendar dates with no time component?

Use Temporal.PlainDate and Temporal.PlainDate.compare. PlainDate has no instant and no zone, so it answers the calendar-date question directly without DST or offset interference.

Does locale ever affect comparison?

Not for chronological ordering — that is always numeric. Locale matters only at display time (field order, month names, digits) and for genuinely text-driven concerns like week-start day or non-Gregorian calendars, which affect grouping and headers, not the underlying sort key.