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.

Compare instants, display locallySorting strings scrambles orderCompare instants, display locallysort localized stringsfield order variessort epoch / Instanttrue chronologyComparison must use absolute values; locale only affects the displayed label.

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.

Comparison APIsOrdering and locale-aware displayComparison APIsTemporal.Instant.compare−1/0/1 by absolute timePlainDate.comparecivil-date orderingepoch ms subtractionnumeric sort keyIntl.DateTimeFormatdisplay only, after sort

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.

Legacy comparisonSubtract Date values; beware time componentsLegacyModerna - b (ms)works for instantsdates carry timespartial-day skewTemporal.comparetyped orderingPlainDate for civilno time leak

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.

Sort then localizeOrder by Instant/PlainDate, format for the readerSort then localizevaluesTemporalcompareIntl formatper locale

Production implementation

The production rule that resolves nearly every "dates sort wrong across locales" issue is a strict separation of concerns: chronological order is universal and locale-independent, so you sort the absolute values, and locale only enters when you display the ordered results. The bug people attribute to locale actually comes from sorting formatted strings, which orders them lexicographically by localized text and scrambles chronology — "avril" before "mars", or an ambiguous "01/02" ordered by its characters. Sort the underlying instants (or PlainDate values for civil dates) with the type's compare static, then apply locale-aware formatting to the already-ordered list.

So the pipeline is parse → sort → format, three distinct stages where the comparison never touches localized text and the formatting never influences order. For instants, compare epochNanoseconds or use Temporal.Instant.compare; for civil dates, use Temporal.PlainDate.compare, which is explicit and type-safe where </> coercion is not. Keep every value in one comparable type so no comparison drags in a coercion or a time-of-day. The one genuinely zone-sensitive decision is grouping sorted results by calendar day, which should use the viewer's zone — but the chronological sort itself is absolute and the same everywhere.

Comparison also underlies deduplication, range membership, and "is A before B" business rules, all of which should run on the typed values rather than on formatted text. Centralizing comparison on the compare statics means one correct, locale-independent ordering serves sorting, grouping, and predicates alike, and the locale-aware layer sits cleanly on top at render time.

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.

Locale-aware sort utilityNormalize, sort by absolute value, then displayLocale-aware sort utilitymixed inputto Instantstable sortlocalize

Edge cases

The headline edge case is the temptation to make sorting itself "locale-aware", which is almost always a bug: there is no locale in which March comes after April chronologically, so a sort that reorders by locale is really a sort of formatted strings in disguise. Keep the sort absolute. A related edge is comparing mixed types — a Date against a PlainDate, or an instant against a civil date — which forces a coercion that introduces a time-of-day or a zone and can flip the result; normalize every value to one comparable type before sorting.

The zone-and-day edge appears when you group sorted results by calendar day for display ("show these by date"). Which day an instant belongs to depends on the zone, so grouping must reduce each instant to a PlainDate in the viewer's zone before bucketing, or the day boundaries follow the host zone and items land in the wrong group near midnight. Finally, stable sorting matters when several items share an exact instant: add a deterministic secondary key (an id) so the order is reproducible across runs rather than dependent on the engine's sort stability, which keeps paginated or cached results consistent.

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.

Comparison edge casesTies, zones, and civil vs absoluteComparison edge casesTiesequal instantsstable sortMixed zonescompare absolutenot wall timeCivil datesPlainDate.compareno zone

Gotchas & common pitfalls

Comparison pitfallsSorting the wrong keyWrongRightsort formatted stringslexicographic messsort epoch / Instantnumeric chronologycompare wall timeszone-dependentcompare absolute valueszone-safe

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

Comparison test matrixOrder is stable and locale-independentAssertions that prove the edge casesort instantsascendingen vs de displaysame orderequal timesstablePlainDate comparecivil order

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.

How do I sort dates correctly regardless of locale?

Sort the absolute values, not formatted strings: compare instants with Temporal.Instant.compare (or PlainDate.compare for civil dates), then format the ordered list for display. Chronological order is universal and locale-independent; only the rendering is locale-sensitive. Sorting formatted strings orders them lexicographically by localized text, which scrambles chronology — that is the actual cause of 'dates sort differently across locales'.

Is date comparison itself affected by locale?

No. Whether one moment is before another is universal, so comparison and sorting are locale-independent. Locale only affects how you display the results. A 'locale-aware sort' that reorders items by locale is almost always sorting formatted strings by mistake. Compare the typed instants or PlainDate values with the compare statics, and keep every value in one comparable type to avoid coercion.

How should I group sorted dates by day for display?

Reduce each instant to a PlainDate in the viewer's zone before bucketing, because which calendar day an instant belongs to depends on the zone. Grouping on raw instants or in the host zone lands items in the wrong day near midnight for users elsewhere. The chronological sort stays absolute; only the day-grouping step is zone-sensitive, and it should use the viewer's zone.

Why should I add a secondary sort key when dates tie?

When several items share an exact instant, add a deterministic secondary key such as an id so the order is reproducible across runs rather than depending on the engine's sort stability. This keeps paginated, cached, or re-sorted results consistent, which matters whenever the same list is rendered more than once or split across pages.