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
The key insight for sorting dates "across locales" is that the sort itself is not locale-sensitive — chronological order is universal — and the locale only enters when you display the sorted results. The bug people fear ("dates sort differently in different locales") actually comes from sorting formatted strings, which orders them lexicographically by their localized text and scrambles chronology. The fix is to sort the underlying instants and format only afterward. Comparing epoch values (or Temporal.Instant.compare) gives a stable, correct order that is identical everywhere, and the locale-appropriate rendering is applied to the already-ordered list.
So the production pipeline is: parse each input to an absolute value, sort by that value, then map to localized strings for display. Keeping parse-sort-format as three distinct stages means the comparison never touches locale-dependent text and the formatting never influences order. For civil dates without a time, compare PlainDate values with Temporal.PlainDate.compare; for instants, compare the instants. The one genuinely locale- and zone-sensitive decision is which calendar day an instant falls on if you are grouping by day — that grouping should use the viewer's zone — but the chronological sort of the instants themselves is absolute.
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
The dominant pitfall is sorting formatted date strings, which orders them by localized text (so "avril" sorts before "mars", or "01/02" is ambiguous) rather than by time. Sort the underlying instants or PlainDate values and format afterward. The second is comparing dates with </> on Date objects and relying on implicit coercion, which works for Date but throws for Temporal types and hides intent; use the explicit compare static. The third is mixing types in the comparator — a Date against a PlainDate — which drags in coercion and a time-of-day; keep every value in one comparable type.
A fourth pitfall is assuming the sort needs to be localized at all; chronological order is universal, and only display is locale-sensitive, so a "locale-aware sort" that reorders by locale is almost always a bug. A fifth is grouping sorted results by calendar day without choosing a zone, so the day boundaries follow the host zone rather than the viewer's; reduce to PlainDate in the viewer's zone before grouping. Separating the absolute sort from the locale-aware display resolves all of these.
- 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.
How do I sort dates correctly across locales in JavaScript?
Sort the underlying absolute values, not formatted strings: compare epoch milliseconds or use Temporal.Instant.compare (or PlainDate.compare for civil dates), then format the already-ordered list for display. Chronological order is universal, so the sort is not locale-sensitive; only the rendering is. Sorting formatted strings is what scrambles order, because it compares localized text lexicographically.
Why does sorting formatted date strings give the wrong order?
Because string comparison is lexicographic over localized text, not chronological: 'avril' sorts before 'mars', and formats like '01/02/2024' are ambiguous and sort by their characters rather than their dates. Formatting also applies a locale and zone that can reorder values. Always sort the typed instants or PlainDate values and format only after sorting.
Is sorting dates a locale-sensitive operation?
No — chronological order is universal, so the sort itself is not locale-sensitive; only the display of the sorted results is. A 'locale-aware sort' that reorders items by locale is almost always a bug caused by sorting formatted strings. The one place a zone matters is if you group sorted results by calendar day, which should use the viewer's zone.