DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four ways to pick a date locale in one app, and only the newest one asks the data

CogniPrep sells assessment practice to candidates in the UK, the US, Australia, New Zealand and a long tail of other places. It is an English-only product, which sounds like it means there is no internationalisation work to do. Then I counted every date in it.

33 files call toLocaleDateString, toLocaleTimeString or Intl.DateTimeFormat. The locale argument is:

'en-GB'                14
'en-US'                13
undefined               6
a user setting          3
derived from a zone     1
a variable              1
no argument at all      2
Enter fullscreen mode Exit fullscreen mode

Four different answers to one question, and they arrived in roughly that order.

The hardcoded pair

The public blog formats dates in en-GB, in two copies of the same function. The index uses a short month, the article uses a long one:

// app/blogs/page.tsx
return new Date(dateStr).toLocaleDateString('en-GB', {
  day: 'numeric', month: 'short', year: 'numeric',
});

// components/blog/ArticleShell.tsx
return new Date(dateStr).toLocaleDateString('en-GB', {
  day: 'numeric', month: 'long', year: 'numeric',
});
Enter fullscreen mode Exit fullscreen mode

You can read both off the deployed HTML:

curl -s https://cogniprep.app/blogs | grep -oE "[0-9]{1,2} [A-Z][a-z]{2,8} 20[0-9]{2}" | head -3
curl -s https://cogniprep.app/blogs/which-assessment-provider-am-i-taking | grep -oE "<time[^>]*>[^<]*"
Enter fullscreen mode Exit fullscreen mode

The index says 7 Sept 2026. The article says 7 September 2026, wrapped in <time dateTime="2026-09-07">, so the machine-readable value is ISO and only the visible string is British.

This one is fine, and the reason it is fine is the month option. A spelled-out month cannot be misread, whichever order it is in. Every ambiguity problem with dates is a numeric-month problem, so the first rule worth having is not about locales at all: never render a date as three numbers.

Meanwhile a signed-in profile page says Member since in en-US, because it was written on a different day by the same person. Nobody noticed, because nobody sees both pages in one sitting.

The six undefined calls

return new Date(iso).toLocaleDateString(undefined, {
  day: 'numeric', month: 'short', year: 'numeric',
});
Enter fullscreen mode Exit fullscreen mode

Passing undefined means "use the runtime default", and the runtime is whichever machine happens to be formatting. On the server that is the ICU default of a Vercel function. In the browser it is the reader's own setting. Those are two different strings for one timestamp, and if the same component ever renders on both sides, React is entitled to complain about it.

It is the most defensible-looking line in the census and the least predictable in practice, because the answer lives in an environment variable on someone else's computer.

The newest module asks the data

The applications tracker we shipped last week stores a deadline with the IANA time zone the candidate's browser reported when they saved it. That gave the module something none of the earlier code had: a fact about the reader, stored next to the date. Its header comment is the clearest statement of the rule in the codebase:

/**
 * Every date is formatted in a locale chosen from the date's own time zone
 * rather than from the browser, for two reasons. The server and the browser
 * must produce the same string or React flags a hydration mismatch, and the
 * server has no browser locale. And a stored zone is a fair signal of how the
 * candidate writes dates: month-first in the Americas, day-first elsewhere, so
 * "3 Nov" is never read as March.
 */
function localeForZone(timeZone: string | null | undefined): string {
  return timeZone && /^(America|US)\//.test(timeZone) ? 'en-US' : 'en-GB';
}
Enter fullscreen mode Exit fullscreen mode

Two things about this are worth stealing. The first is that deriving the locale from stored data, rather than from the environment, makes the output identical on the server and in the browser by construction. There is no hydration question to answer because there is no second opinion.

The second is browserTimeZone(), which is how the fact gets stored in the first place:

/** The browser's IANA zone, sent with every date the candidate saves. */
export function browserTimeZone(): string {
  try {
    return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
  } catch {
    return 'UTC';
  }
}
Enter fullscreen mode Exit fullscreen mode

One call, wrapped in a try because a locked-down browser can throw on it, and from then on the zone is data rather than inference.

The regex is the part I would change. It exists twice, once in the display helper and once in the reminder email builder, with the same comment restated:

// US-style dates for American zones, day-first everywhere else. The zone is the
// best signal we hold of where the reader is, and "3 November" read as March in
// New York is exactly the mistake a deadline email cannot make.
const locale = /^(America|US)\//.test(item.timeZone) ? 'en-US' : 'en-GB';
Enter fullscreen mode Exit fullscreen mode

Two copies of a rule that will eventually need a third branch, and the email cannot import the client helper without dragging a component module into a job. The fix is a shared function in the data layer, not a cleverer regex.

The locale that is not a display choice

Two more en-US calls are not display code at all:

const parts = new Intl.DateTimeFormat('en-US', {
  timeZone,
  hourCycle: 'h23',
  year: 'numeric', month: '2-digit', day: '2-digit',
  hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(instant);
Enter fullscreen mode Exit fullscreen mode

That is Intl used as a time zone library: it is the cheapest way to ask what the wall clock reads in Europe/Amsterdam without shipping a dependency. The locale here is arbitrary but must be fixed, and hourCycle: 'h23' is what stops a locale that prefers 12-hour clocks from handing you an hour part of 12 at midnight.

A third en-US is a validity check:

try {
  new Intl.DateTimeFormat('en-US', { timeZone: value });
  return true;
} catch {
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Constructing a formatter to find out whether a string is a real zone, because there is no Intl.isValidTimeZone. Both of these are good uses, and both of them pollute a grep for display locales, which is why the census above separates them.

The one place the reader chooses

The three "a user setting" calls read the exercise spelling preference, which exists so that written-exercise material can say "organize" or "organise". Its stored values are 'en-GB' and 'en-US', which means they double as locale tags:

date: new Date(report.completedAt).toLocaleDateString(spelling, { ... })
Enter fullscreen mode Exit fullscreen mode

One cookie, two jobs: the vocabulary of the exercise text and the order of the date on the report. That only works because whoever typed that type out picked BCP 47 tags rather than 'uk' | 'us', which would have quietly formatted dates in Ukrainian. Store settings as the standard identifier for the thing they are, and they compose with the platform for free. The exercises it governs are listed on the assessment centre practice page; the preference itself sits inside a running exercise, so it is one of the few claims in this post you cannot check from outside.

Where this is going

One module, one formatDate, one rule for picking the locale, and the only legitimate arguments to a date formatter are a literal from that module or a value read out of the row being displayed. undefined is not an answer; it is a way of not writing one down.

If you want to run the same census on your own app, the grep worth running is not toLocaleDateString but the argument:

grep -rho "toLocaleDateString([^,)]*\|Intl\.DateTimeFormat([^,)]*" app components lib \
  | sed "s/.*(//" | sort | uniq -c | sort -rn
Enter fullscreen mode Exit fullscreen mode

If that prints more than two lines, the dates in your product are telling different readers different things.

Top comments (0)