Sort Dates Correctly Across Locales
To sort dates reliably no matter the user's locale, order them by their absolute instant — date.getTime() or Temporal.Instant.compare — and apply locale formatting only afterward. Part of Locale-Sensitive Date Comparison and Sorting.
Why this scenario is tricky
The trap is sorting the values you can see. A locale-formatted date like 15/03/2024 (en-GB) or 03/15/2024 (en-US) is just text, and Array.prototype.sort with localeCompare orders text alphabetically. Day-first text sorts 01/04 before 15/03 before 28/02 — chronologically backwards. Worse, the result depends on the locale used to format: the same array reorders when a German user (15.03.2024) and a US user (3/15/2024) view it, and month-name locales (15 mars) sort by the spelling of the month. None of these correspond to real time order.
The fix is to recognize that chronological order is a numeric property of the absolute instant. Every Date already stores epoch milliseconds; every Temporal.Instant stores epoch nanoseconds. Comparing those numbers is locale-independent, host-zone-independent, and deterministic. Formatting is a one-way projection for the eyes — you sort the data, then format the sorted data, never the reverse.
Minimal working solution
const input = ['2024-03-15T00:00:00Z', '2024-04-01T00:00:00Z', '2024-02-28T00:00:00Z'];
// Compare epoch milliseconds (absolute instant); negative result => a sorts before b.
const sorted = input
.map((iso) => new Date(iso))
.sort((a, b) => a.getTime() - b.getTime());
console.log(sorted.map((d) => d.toISOString().slice(0, 10)));
// ['2024-02-28', '2024-03-15', '2024-04-01'] — correct in every locale
The getTime() subtraction is the whole technique: it reduces each date to a number, and numbers have a single, unambiguous order. The same approach with Temporal uses the static comparator directly:
import { Temporal } from '@js-temporal/polyfill';
const instants = input.map((iso) => Temporal.Instant.from(iso));
instants.sort(Temporal.Instant.compare); // returns -1 | 0 | 1 — exactly what sort wants
Full production version
This handles Date, ISO strings, and Temporal inputs, validates each value, and supports ascending or descending order. It never touches a formatter, so its output order is identical on any host.
import { Temporal } from '@js-temporal/polyfill';
type DateLike = Date | string | Temporal.Instant | Temporal.ZonedDateTime;
type Direction = 'asc' | 'desc';
// Reduce any supported input to epoch nanoseconds (BigInt) — the absolute instant.
function toEpochNs(value: DateLike): 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 passed to sortDates');
return BigInt(ms) * 1_000_000n; // milliseconds -> nanoseconds
}
if (typeof value === 'string') {
// Instant.from requires an offset or trailing Z; it throws on offset-less strings.
return Temporal.Instant.from(value).epochNanoseconds;
}
throw new TypeError(`Unsupported value: ${String(value)}`);
}
export function sortDates<T extends DateLike>(items: readonly T[], dir: Direction = 'asc'): T[] {
const sign = dir === 'asc' ? 1n : -1n;
return items
.map((item) => ({ item, key: toEpochNs(item) })) // compute key once per item
.sort((x, y) => Number((x.key < y.key ? -1n : x.key > y.key ? 1n : 0n) * sign))
.map((entry) => entry.item);
}
// Display step is separate: sort first, then format with an explicit timeZone + locale.
const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', dateStyle: 'medium' });
const rows = ['2024-04-01T00:00:00Z', '2024-02-28T00:00:00Z', '2024-03-15T00:00:00Z'];
console.log(sortDates(rows).map((iso) => fmt.format(new Date(iso))));
// ['28 Feb 2024', '15 Mar 2024', '1 Apr 2024']
Verification snippet
import { Temporal } from '@js-temporal/polyfill';
const data = ['2024-04-01T00:00:00Z', '2024-02-28T00:00:00Z', '2024-03-15T00:00:00Z'];
// Right: numeric epoch order is the chronological truth.
const right = [...data].sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime()
);
// Wrong: localeCompare on de-DE-formatted strings reorders by glyphs.
const deFmt = new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', dateStyle: 'short' });
const wrong = [...data].sort((a, b) =>
deFmt.format(new Date(a)).localeCompare(deFmt.format(new Date(b)))
);
console.assert(
JSON.stringify(right) === JSON.stringify([
'2024-02-28T00:00:00Z',
'2024-03-15T00:00:00Z',
'2024-04-01T00:00:00Z',
]),
'epoch sort must be chronological'
);
// '01.03'-style day/month text sorts differently from the correct instant order.
console.assert(
JSON.stringify(wrong) !== JSON.stringify(right),
'string sort is expected to disagree with chronological order'
);
// Locale-independence: sorting again under a different formatter does not change order.
const enFmt = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
const rightAgain = [...data].sort(
(a, b) => Temporal.Instant.from(a).epochNanoseconds < Temporal.Instant.from(b).epochNanoseconds ? -1 : 1
);
void enFmt; // formatter unused for ordering — order is identical regardless of locale
console.assert(JSON.stringify(rightAgain) === JSON.stringify(right), 'order is locale-independent');
Common pitfalls
- Sorting formatted output. Wrong:
arr.sort((a, b) => fmt.format(a).localeCompare(fmt.format(b))). Right:arr.sort((a, b) => a.getTime() - b.getTime())— compare instants, format later. - Lexicographic sort of mixed-format strings. Wrong:
['3/15/2024', '12/1/2023'].sort()(string order puts12/...before3/...). Right: parse toDate/Temporal.Instantfirst, then sort numerically. localeComparefor chronology. Wrong: treatinglocaleCompareas a date comparator. Right: reservelocaleCompare/Intl.Collatorfor human-readable labels; use epoch numbers for time.- Offset-less ISO strings. Wrong:
Temporal.Instant.from('2024-03-15T12:00:00')throws. Right: include aZ/offset, or anchor viaPlainDateTime.from(...).toZonedDateTime(tz, { disambiguation: 'compatible' })before sorting. For deeper zoned-comparison rules see comparing ZonedDateTime across timezones.
Frequently Asked Questions
Can I just call .sort() on an array of Date objects?
No. The default sort converts elements to strings, so Date objects are sorted by their toString() text, which is host-timezone dependent. Always pass a comparator: (a, b) => a.getTime() - b.getTime().
How do I sort newest-first?
Reverse the comparator: (a, b) => b.getTime() - a.getTime(), or pass 'desc' to the sortDates utility above. The basis is still the numeric epoch — only the sign flips.
Where does locale formatting belong then?
After sorting, at render time only. Build a cached Intl.DateTimeFormat with an explicit timeZone and apply it to the already-sorted array, as shown in the production example.