PATTERN Cited by 1 source
Backend localization for hydration stability¶
Pattern. When a React component needs to display a
locale-formatted date, number, currency, or relative-time, do
not call Intl.* or toLocaleString in the component. Instead,
have the backend pre-compute the localised string and pass it to
the frontend as plain text. The component renders the string
verbatim on both SSR and CSR, producing byte-identical markup.
This eliminates an entire class of hydration mismatches — the locale / host-default SSR/CSR divergences — architecturally, not by suppressing or reactively fixing them.
Shape¶
Instead of (frontend localises; SSR and CSR can disagree):
// Frontend component.
<div>
{date.toLocaleString(locale, { timeZone })}
{new Intl.NumberFormat(locale).format(price)}
</div>
Do (backend localises; frontend renders plain text):
The hydration contract is now trivial: SSR and CSR both render
the same string from the same API payload. No Intl call on the
client.
(Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)
Why this is sometimes the only fix¶
The Zalando Rendering Engine post documents the concrete worked-example of a mismatch that cannot be fixed application-side even with explicit locale and timezone:
"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.)
This is a divergence between JavaScriptCore's Intl
implementation in Safari and V8's in Node.js, below the
application layer. The only reliable fix is to not call Intl
on the client. Backend localization makes this automatic for
all such cases, present and future.
Where localization happens in this pattern¶
- The backend has a canonical locale for the request (from URL path, Accept-Language, user preference, etc.).
- The backend calls
Intl.*(in Java, Python, Go, Node — whatever the backend stack is) and produces a string. - The string travels through the API.
- The frontend renders the string without transformation.
The trade-off: every localised field is now on the backend's
schema. A field that used to be "price": 2345 becomes
"priceDisplay": "2 345,00 €" (or sometimes both: "price": 2345,
"priceDisplay": "..." when the frontend still needs the numeric
value for sorting, comparison, etc).
Trade-offs¶
Wins¶
- Hydration mismatch is eliminated for this class. No matter what browser the user is on, SSR and CSR produce identical text.
- Single source of truth for localization logic. Complex formatting rules (plural, currency-rounding, list formatting) live in one codebase, not duplicated.
- Backend can use better locale data. Java's ICU library,
Python's Babel, Go's golang.org/x/text — often more up-to-date
than browser
Intlimplementations, particularly for less-common locales. - Runtime-implementation divergence eliminated. No more Safari-vs-Node bugs.
Costs¶
- API payload size grows. Every localised view of a value is an extra field.
- Schema coupling to display.
priceDisplayis a presentation concern leaking into the API. Mitigations: - Keep the numeric value alongside for non-display uses (sorting, filtering).
- Use a BFF (concepts/backend-for-frontend) so the presentation-enriched schema is per-frontend, not shared with mobile / machine API consumers.
- Per-locale caching fragmentation. Any edge cache now keys per locale (or per request Accept-Language). Cache-hit rate may regress.
- Client cannot reformat. If the user switches locale client-side, every localised value needs a backend re-fetch. (For most sites, locale doesn't change without a navigation, so this is usually fine.)
Partial variants¶
- Backend for complex formats, frontend for simple. Move currency and dates to backend; leave simple number formatting on client (with explicit locale).
- Backend for hydration-sensitive paths only. Above-the- fold content backend-localised; below-the-fold client-side (served after hydration completes, so no mismatch possible).
- Backend on first request, client-side on subsequent interactions. SSR response has pre-localised strings; client-side mutations recompute client-side. Introduces a different class of consistency issue — handle with care.
Relationship to other hydration-mismatch fixes¶
| Root cause | Recommended fix |
|---|---|
| Timer / time-delta | patterns/suppress-hydration-warning-for-unavoidable-mismatch |
| Locale / timezone (fixable) | Pass explicit args consistently, OR backend-localize |
| Locale / runtime divergence (Safari Intl bug) | Backend-localize (this pattern) — only fix |
| Genuinely different SSR vs CSR content | patterns/mount-gated-client-only-rendering |
| Invalid HTML nesting | Fix the nesting (lint) |
Seen in¶
- sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react
— canonical wiki instance. Explicitly recommended as the
fix for Zalando's Safari-
de-AT-Intl.NumberFormatdivergence, where no application-side fix is possible. Also recommended as the general architectural fix for timezone/locale drift: "depending on the situation and product requirements, an alternative approach would be to just move the conversion to the backend so that the client simply receives dates in the localized format."
Related¶
- concepts/locale-host-default-ssr-csr-divergence — the problem class.
- concepts/hydration-mismatch — the umbrella concept.
- concepts/react-hydration — the mechanism.
- patterns/mount-gated-client-only-rendering — alternative for genuinely-client-only content.
- patterns/suppress-hydration-warning-for-unavoidable-mismatch — alternative for semantically-equivalent mismatches.