JavaScript & the runtime

Dates, numbers and Intl

Why Date is a trap and how to store and format time correctly across server and client, Temporal, the Intl formatters for dates, numbers, currencies, plurals, lists and relative time, floating point and money, and caching formatters.

Every product has dates and money, and every SSR app has had the bug where the server rendered "21/09/2026" and the browser rendered "9/21/2026". This page is the practical knowledge interviewers check with one small task: "format this timestamp for the user", or "why is 0.1 + 0.2 not 0.3 and what do you do about prices".

Know

  • Date is a millisecond timestamp with a local-time API. Months are zero-based, it is mutable, Date.parse of non-ISO strings is implementation-defined, and every getHours/toLocaleString call depends on the runtime's time zone and locale, which differ between your server (usually UTC, en-US) and the user's browser. That difference is the classic hydration mismatch.
  • Rules of thumb: store and transmit ISO 8601 in UTC (2026-09-21T08:00:00.000Z) or epoch milliseconds; keep the user's IANA time zone (Intl.DateTimeFormat().resolvedOptions().timeZone) and locale as explicit inputs; format at the edge with Intl, passing locale and timeZone explicitly so server and client agree; never do calendar arithmetic with setDate across DST boundaries by hand.
  • Temporal (finished proposal, shipping progressively; polyfill for production): immutable types PlainDate, PlainTime, PlainDateTime, ZonedDateTime, Instant, Duration, calendar-aware arithmetic, explicit time zones, no implicit local time. The libraries in between: date-fns (functions over Date, tree-shakable), Day.js (small, plugin-based), Luxon (Intl-based); Moment is legacy.
  • Intl.DateTimeFormat: dateStyle/timeStyle presets or component options, timeZone, hour12, formatRange, formatToParts for custom layouts. Intl.RelativeTimeFormat ("3 days ago", numeric: 'auto' for "yesterday"). Intl.DurationFormat (Baseline 2025) for "2 h 30 min".
  • Intl.NumberFormat: style: 'currency' with currency, currencyDisplay, notation: 'compact' ("1.2M"), style: 'unit' (unit: 'kilometer-per-hour'), style: 'percent', signDisplay, minimumFractionDigits/maximumFractionDigits, roundingMode ('halfEven' for banker's rounding), formatRange, formatToParts. Intl.PluralRules for one/few/many/other categories (Czech has few and many), Intl.ListFormat ("a, b and c"), Intl.DisplayNames (language and region names), Intl.Collator for locale-aware sorting (numeric: true for "file2" before "file10"), Intl.Segmenter for grapheme and word boundaries (emoji length, truncation).
  • Floating point: numbers are IEEE 754 doubles; 0.1 + 0.2 === 0.30000000000000004, integers are exact only up to Number.MAX_SAFE_INTEGER (2^53 − 1), toFixed rounds on the binary value ("1.005".toFixed(2) is "1.00"). Money is stored in minor units as integers (cents) or as decimal strings handled by a decimal library (big.js, decimal.js, dinero.js); BigInt for large integer ids (no mixing with Number, not JSON-serialisable without a reviver, see JSON.rawJSON). Math.sumPrecise (ES2026) sums doubles exactly.
  • Parsing: Number('12px') is NaN, parseFloat('12px') is 12, parseInt needs a radix, Number.isNaN versus the coercing global isNaN; Number.isInteger, Number.isSafeInteger. User input in locales with a decimal comma needs Intl-aware parsing or a library, because Number('1,5') is NaN.
  • Performance: Intl formatter construction is expensive (locale data lookup); cache formatters per locale and options, and format in a computed. This app's formatDate helper and Nuxt's <NuxtTime> do exactly that.
  • In Nuxt: the locale and time zone come from the URL, a cookie or Accept-Language so both sides see the same values; <NuxtTime> renders consistent <time> elements; @nuxtjs/i18n exposes n() and d() over Intl with formats defined once.

How it works

Formatters that agree between server and client, cached per locale:

shared/utils/format.ts
const numberFormatters = new Map<string, Intl.NumberFormat>()
const dateFormatters = new Map<string, Intl.DateTimeFormat>()

export function formatMoney(minorUnits: number, currency: string, locale: string) {
  const key = `${locale}:${currency}`
  const fmt = numberFormatters.get(key) ?? numberFormatters.set(key, new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    roundingMode: 'halfEven',
  })).get(key)!
  return fmt.format(minorUnits / 100) // 12345 -> "123,45 €" in cs-CZ, "€123.45" in en-IE
}

export function formatDate(iso: string, locale: string, timeZone: string, style: Intl.DateTimeFormatOptions['dateStyle'] = 'medium') {
  const key = `${locale}:${timeZone}:${style}`
  const fmt = dateFormatters.get(key) ?? dateFormatters.set(key, new Intl.DateTimeFormat(locale, {
    dateStyle: style,
    timeStyle: 'short',
    timeZone,
  })).get(key)!
  return fmt.format(new Date(iso))
}

export function formatRelative(iso: string, locale: string, now = Date.now()) {
  const diffMs = new Date(iso).getTime() - now
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
  const minutes = Math.round(diffMs / 60_000)
  if (Math.abs(minutes) < 60) return rtf.format(minutes, 'minute') // "in 5 minutes", "yesterday" with numeric auto for days
  const hours = Math.round(minutes / 60)
  if (Math.abs(hours) < 24) return rtf.format(hours, 'hour')
  return rtf.format(Math.round(hours / 24), 'day')
}

Using them in a component with locale and time zone from the request rather than the runtime:

app/components/Order/Row.vue
<script setup lang="ts">
const props = defineProps<{ order: { totalMinor: number, currency: string, placedAt: string } }>()
// Both values are known on the server and the client: a cookie for the zone, the i18n locale for the language.
const { locale } = useI18n()
const timeZone = useCookie('tz', { default: () => 'Europe/Prague' })
const total = computed(() => formatMoney(props.order.totalMinor, props.order.currency, locale.value))
const placed = computed(() => formatDate(props.order.placedAt, locale.value, timeZone.value))
</script>

<template>
  <tr>
    <td><NuxtTime :datetime="order.placedAt" :locale="locale" :time-zone="timeZone" date-style="medium" time-style="short" /></td>
    <td>{{ total }}</td>
    <td class="text-muted">{{ placed }}</td>
  </tr>
</template>

Plurals, lists and sorting the way native speakers expect:

intl-misc.ts
const plural = new Intl.PluralRules('cs')
const forms = { one: 'položka', few: 'položky', many: 'položky', other: 'položek' } as const
const label = (n: number) => `${n} ${forms[plural.select(n) as keyof typeof forms]}` // 1 položka, 3 položky, 5 položek, 2.5 položky

new Intl.ListFormat('en', { type: 'conjunction' }).format(['Vue', 'Nuxt', 'Nitro']) // "Vue, Nuxt, and Nitro"

const collator = new Intl.Collator('cs', { numeric: true, sensitivity: 'base' })
['soubor10', 'soubor2', 'Čáp', 'cíl'].sort(collator.compare) // cíl, Čáp, soubor2, soubor10

[...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment('👨‍👩‍👧‍👦')].length // 1, where '👨‍👩‍👧‍👦'.length is 11
Gotcha· toLocaleString in a server-rendered template

{{ new Date(order.placedAt).toLocaleString() }} uses the server's locale and zone during SSR and the browser's during hydration: different text, a hydration mismatch, and wrong information for the user. Pass locale and timeZone explicitly from data both sides have, or use <NuxtTime>.

Gotcha· Floats for money

0.1 + 0.2 and 19.99 * 3 are not what a receipt says. Keep amounts as integer minor units (or decimal strings) through the API and the database, do arithmetic on integers, and let Intl.NumberFormat place the decimal separator and symbol at display time.

Verify before the interview:

Temporal's browser support the week of the interview (which engines ship it unflagged) and whether Intl.DurationFormat is Widely available.

docs ↗

Exercise

Exercise
  • Render new Date().toLocaleString() in a Nuxt page, load it with the browser set to a different locale than the server, and read the hydration warning; fix it with explicit Intl options and again with <NuxtTime>.
  • Write formatMoney and a test table for cs-CZ, de-DE, en-US, ja-JP with the same minor-unit amount.
  • Sort a list of Czech names and a list of file names with and without Intl.Collator; compare the orders.

Be able to say

Be able to say· How do you handle dates in a server-rendered app without hydration mismatches?

"I keep dates as ISO 8601 UTC strings or timestamps in data and in the API, and I treat the user's locale and time zone as explicit inputs that both the server and the client know, from the URL, a cookie or Accept-Language, rather than from the runtime. Formatting happens at the edge with Intl.DateTimeFormat, passing locale and timeZone explicitly and caching the formatter per combination because constructing one is expensive. In Nuxt, <NuxtTime> does this and produces a proper <time> element. Calendar arithmetic goes through Temporal with a polyfill, or date-fns, never through setDate across DST by hand."

Be able to say· Why is 0.1 + 0.2 not 0.3, and how do you handle prices?

"Numbers are IEEE 754 binary doubles, and 0.1 and 0.2 have no exact binary representation, so the sum carries a rounding error that shows up as 0.30000000000000004. For money I never store or compute in floats: amounts travel as integer minor units, cents, or as decimal strings handled by a decimal library, arithmetic happens on integers, and Intl.NumberFormat with the right currency and rounding mode formats the value for display. toFixed is not a fix, because it rounds the already-inexact binary value; Math.sumPrecise from ES2026 helps when I genuinely must sum doubles exactly."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.