Skip to content

CONCEPT Cited by 1 source

Locale / host-default SSR/CSR divergence

A class of hydration mismatches where the same Intl.* or toLocaleString call produces different output on the server than on the client because the API defaults to the host's locale or timezone, and the SSR host (usually Node.js in a data-centre) and the CSR host (the user's browser) are different hosts.

The divergence has three variants, described in descending fixability:

Variant 1: Host-default timezone

Code:

// Intentionally no timeZone parameter.
new Intl.DateTimeFormat(locale).format(date);
date.toLocaleString(locale);

Intl.DateTimeFormat without { timeZone } uses the host's timezone. A container in UTC formats 2023-01-01T20:00:00Z as "01.01.2023, 20:00:00"; the same code in a Berlin-timezone browser formats it as "01.01.2023, 21:00:00".

At hydration time React compares the two strings. Mismatch.

Fix (application layer)

Pass an explicit timeZone consistently on both sides:

const UNIVERSAL_TZ = "Europe/Berlin"; // or a website-domain-mapped tz
date.toLocaleString(locale, { timeZone: UNIVERSAL_TZ });
new Intl.DateTimeFormat(locale, { timeZone: UNIVERSAL_TZ }).format(date);

Trade-off: the user's local timezone is never used — the displayed time is the same for everyone in a given locale. This is acceptable for event times advertised with a canonical timezone (e.g. sale starting at 20:00 Central European Time) and not acceptable for relative displays ("2 hours ago", where the user's clock is the reference).

(Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

Variant 2: Host-default locale for numbers

Code:

(12345).toLocaleString(); // no locale argument

Same shape — host locale applied when none specified. Pass a universal locale consistently:

(12345).toLocaleString(universalLocale);

Variant 3: Runtime-implementation divergence (unfixable

application-side)

Even with explicit locale and timezone, different JavaScript runtimes can produce different output for the same call. The Zalando Rendering Engine post documents a concrete case:

"We particularly encountered this issue with the Safari browser where for the de-AT locale, the localisation APIs (like Intl.NumberFormat or tolocalestring) generate values like "2.345" but other browsers including Chrome and Firefox as well as Node.js generate values like "2 345" for the same locale!" (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

The thousand-separator differs between Safari (.) and Node.js (, a narrow no-break space) for the same de-AT Intl.NumberFormat call. No application-level code can resolve this; the mismatch is between JavaScriptCore (Safari) and V8 (Node.js) ICU library versions / CLDR data baselines.

Fix (architecture layer)

Move the localization to the backend:

Backend:  number → Intl.NumberFormat(locale) on Node once → localised string
API:      response carries `"priceDisplay": "2 345,00 €"`
Frontend: renders priceDisplay verbatim

The component now renders a plain string; SSR and CSR produce identical markup because neither side calls Intl. The mismatch is architected away. See patterns/backend-localization-for-hydration-stability.

Structural principle

The class of mismatch this concept covers is any rendering behaviour whose defaults depend on the host runtime's configuration — not just locale and timezone, but anything of similar shape: navigator.* features, matchMedia queries, process-wide Intl data baseline. The architectural fix is always the same shape: resolve the host-dependent value on one side only, carry the resolved value in the payload, render it verbatim on both.

Specific APIs at risk

  • Intl.DateTimeFormat without timeZone.
  • Intl.NumberFormat (with the Safari-de-AT-thousand-separator failure even with explicit locale).
  • Date.prototype.toLocaleString / toLocaleDateString / toLocaleTimeString — same shape.
  • Intl.RelativeTimeFormat — produces different outputs for different current-time references.
  • Intl.Collator — locale-dependent string ordering.
  • Intl.ListFormat / Intl.PluralRules / Intl.DisplayNames / Intl.Segmenter — all follow the same host-default rule.

Interaction with timer-based mismatches

Category 1 of the Zalando taxonomy (timers) is "unfixable, so suppress" (patterns/suppress-hydration-warning-for-unavoidable-mismatch). This category (locale) is "fixable by either passing explicit args or moving to backend". The distinction matters because blanket-suppressing locale mismatches hides real bugs, while not suppressing timer mismatches pollutes Sentry.

Seen in

Last updated · 501 distilled / 1,218 read