Skip to content

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 divergencesarchitecturally, 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):

// Backend API response.
{
  "dateDisplay": "01.01.2023, 20:00:00",
  "priceDisplay": "2 345,00 €"
}
// Frontend component.
<div>
  {dateDisplay}
  {priceDisplay}
</div>

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-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.)

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 Intl implementations, 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. priceDisplay is 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-reactcanonical wiki instance. Explicitly recommended as the fix for Zalando's Safari-de-AT-Intl.NumberFormat divergence, 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."
Last updated · 501 distilled / 1,218 read