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:
Same shape — host locale applied when none specified. Pass a universal locale consistently:
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-ATlocale, the localisation APIs (likeIntl.NumberFormatortolocalestring) 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.DateTimeFormatwithouttimeZone.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¶
- sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react
— canonical wiki instance. Three-variant taxonomy with
concrete code pairs, the Safari
de-ATIntl.NumberFormatdivergence documented as the unfixable-application-side case that forces the move-to-backend architectural fix.
Related¶
- concepts/hydration-mismatch — the parent class.
- concepts/react-hydration — the mechanism that surfaces it.
- patterns/backend-localization-for-hydration-stability — the architectural fix.
- systems/nodejs — the typical SSR host whose Intl implementation diverges from browser hosts.