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.
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
- Sorting formatted strings —
localeCompareonformat()output orders by glyphs, not time. Fix: sort bygetTime()/Temporal.*.compare, format afterward. new Date(a) - new Date(b)left implicit — relying on coercion is fine numerically but fragile under linters andTemporal. Fix: be explicit with.getTime()or.epochNanoseconds.compare(...) === 0used as full equality — it only means same instant. Fix: use.equals()when zone/calendar identity matters.- Sorting
DatebytoString()/toLocaleString()— host-zone and locale dependent, so order changes per machine. Fix: never derive order from any string form. - Mixing
PlainDateandInstantin one comparator — different axes silently produce nonsense. Fix: normalize all items to one type first.
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.