Skip to content

SYSTEM Cited by 3 sources

Zalando Rendering Engine

What it is

The Rendering Engine is the runtime core of Zalando's Interface Framework. It is a Node.js backend service and a browser runtime that, for any given request, resolves a tree of Entities and transforms it into a tree of matching Renderers to produce the page the customer sees (Source: sources/2021-03-10-zalando-micro-frontends-from-fragments-to-renderers-part-1).

It sits at the centre of the IF architecture:

  GraphQL API ──→ Rendering Engine ──→ HTML + hydrated React
                  (Node.js server;
                   browser runtime)
                  uses:
                  - rendering rules (Entity → Renderer)
                  - Zalando Design System
                  - Renderers contributed by feature teams

What it does

Per the Part 1 post (Source: sources/2021-03-10-zalando-micro-frontends-from-fragments-to-renderers-part-1):

  1. Receives an Entity tree whose structure is chosen by Zalando's Recommendation System (or other personalisation services) for this specific request and customer. An example from the post: a product page's in-article slots get filled with "a collection, an outfit, and another collection" chosen server-side.
  2. Applies rendering rules — a declarative mapping from Entities to Renderers — to pick how each Entity in the tree should be visualised. The same Entity type can map to several Renderers depending on placement (an Outfit Entity might render as a main view, or as a card inside a Collection).
  3. Invokes each Renderer, a self-contained piece of React that declares its GraphQL data dependencies and pulls from the GraphQL aggregation layer.
  4. Renders via the Zalando Design System, which means the Rendering Engine takes over component version management and client bundle-size optimisation across Renderers — feature teams do not hand-tune either.

Hybrid rendering modes

A single Rendering Engine can serve three kinds of view configuration (Source: sources/2021-03-10-zalando-micro-frontends-from-fragments-to-renderers-part-1):

  • Mosaic Template only — legacy path; view is composed entirely of Mosaic Fragments.
  • Mixed — view contains Renderers and Fragments.
  • Renderers only — the modern IF path.

This hybrid capability is the migration load-bearer: it let Zalando swap Fragments for Renderers incrementally while keeping a single serving substrate. By March 2021, ~90% of traffic is served through the Rendering Engine, with the residual hybrid-mode tail mostly Mosaic-era Fragments not yet rewritten.

Why it runs in both Node.js and the browser

The post names the Rendering Engine explicitly as running "in Node.js and the browser" (Source: sources/2021-03-10-zalando-micro-frontends-from-fragments-to-renderers-part-1). That is a universal-rendering shape (see concepts/universal-rendering): the same tree-resolution and Renderer-invocation logic executes server-side to produce the initial HTML response, and client-side to resume rendering, hydrate, and re-render as the Entity tree or personalisation state changes. Keeping one Engine on both sides is what makes the "Renderer" a single unit that contributors write once and trust to run in both environments.

Known gaps (Part 1 post)

The Part 1 post names the Rendering Engine but does not describe:

  • the rendering rules language / DSL beyond "declarative set of layout rules",
  • whether it supports streaming SSR (concepts/streaming-ssr) or classic SSR,
  • the caching strategy (per-Renderer output cache? per Entity subtree?),
  • failure isolation across Renderers — if one Renderer throws, what does the page do?
  • the concurrency / backpressure model of the Node.js server,
  • the exact hot path for a page with 10+ Renderers.

These are flagged for upcoming posts in the series.

React 18 / Concurrent rendering migration (2023)

The 2023-07 Rendering Engine Tales post extends the RE story with a React 18 migration chapter, answering one of the gaps above ("streaming SSR? classic SSR?") retroactively: pre-React-18 RE already had partial hydration, partial streaming, and lazy-loading built in-house, and the React 18 migration is partly about delegating those to a supported upstream implementation.

Architectural fit

"Rendering Engine's own partial hydration/streaming features! … React's concurrent rendering APIs seamlessly integrate with the architecture of RE because its Renderers serve as ideal candidates for being encapsulated within a Suspense boundary. This enables them to function as individual blocks that can be server-rendered, streamed, hydrated, and client-rendered 'concurrently'." (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

Each Renderer → one <Suspense> boundary is the migration's core architectural mapping. The Renderer-per-Entity granularity (see patterns/entity-to-renderer-mapping) turns out to be the right grain for React 18's per-boundary progressive hydration.

Design choice: Application-State layer outside React

Rather than move data resolution into React hooks (the Render-As-You-Fetch pattern), RE created an Application-State layer that sits outside React and dictates Suspense state from the outside. See patterns/application-state-layer-outside-react for the canonical pattern page.

Four upstream blockers drove this decision away from pure hook-based RAYF:

  1. SuspenseList is experimental with limitations (breaks RE's ordered-streaming requirement).
  2. useTransition doesn't consider nested Suspense boundaries (bad UX in RE's deeply-nested Renderer trees).
  3. Hook-initiated fetches couple fetch timing to render order (performance anti-pattern when siblings could parallelise).
  4. React's streaming/caching layer for data supply (facebook/react#25502) wasn't final at PoC time.

Pipeline shape:

resolveEntity (RE)  →  writes to  →  Application State
                                             │ read
                                     Connector hook
                                 (returns data OR throws Promise)
                                   <Suspense>-wrapped Renderer

The Connector hook is framed in the post as "Imagine Redux's useSelector hook, but instead of immediately returning selected data you get a Promise that only resolves once a reducer has made the data available."

Measured impact of the narrow API swap

A milestone before the full Application-State-layer rollout: RE's internal streaming + hydration APIs were swapped to React 18 equivalents — renderToPipeableStream replaced renderToNodeStream; hydrateRoot replaced hydrate. No other concurrent-feature adoption. Rolled as an A/B test across all Fashion Store pages (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react):

Overall:

Metric Δ
INP −5.69 %
FID −8.81 %
LCP −2.43 %
FCP −0.23 %
Bounce rate −0.24 %

Per page:

Metric Home Catalog PDP
INP −2.92 % −6.76 % −6.09 %
FID −2.98 % −17.11 % −6.06 %
Exit Rate −0.43 % −0.06 % −0.06 %

Interactive-latency metrics improved most; the biggest wins were on Catalog (the most Renderer-dense page).

Hydration-mismatch taxonomy surfaced

The React 18 hydration contract is much stricter than React 17's, and the migration surfaced dozens of different types of issues across hundreds of Renderers. The four load-bearing categories documented in the post (see concepts/hydration-mismatch for the full taxonomy):

  1. Timers / time-deltas — SSR and CSR compute the delta at different instants. Fix: suppressHydrationWarning={true} (patterns/suppress-hydration-warning-for-unavoidable-mismatch).
  2. Timezone-localised datesIntl.DateTimeFormat defaults to host timezone; Node-in-UTC and the user's browser disagree. Fix: pass explicit timeZone, or move localization to backend (patterns/backend-localization-for-hydration-stability).
  3. Number locale / Safari de-AT Intl.NumberFormat bug — Safari's thousand-separator for de-AT is "." while Node's is narrow-no-break-space. Unfixable application-side (it's below the app layer). Fix: backend-localise.
  4. Invalid HTML nesting (<div> in <p>, <button> in <button>) — React 18 treats structurally-invalid HTML as a mismatch. Fix: semantically valid nesting, enforced via eslint-plugin-validate-jsx-nesting.

General escape hatch for genuinely-client-only content: patterns/mount-gated-client-only-rendering.

Sentry integration and error dedup

React's onRecoverableError fires multiple times per single mismatch because post-first-error the fiber-vs-DOM list alignment breaks and subsequent nodes also fail. Zalando's production fix is to only forward the first hydration error per session to Sentry — see patterns/first-error-only-hydration-error-reporting.

Deferred to future posts

  • Ordered streaming/hydration technical solution: "We will share the details of the technical solution for ordered streaming/hydration in another post."
  • Final architecture effects on Fashion Store performance (beyond the narrow-API-swap A/B-test deltas above).
  • React Server Components adoption.

Extension to mobile (2025-10)

As of Zalando's 2025-10-02 disclosure (sources/2025-10-02-zalando-accelerating-mobile-app-development-with-rendering-engine-and-react-native), Rendering Engine is no longer web-only. Zalando is using Rendering Engine as the application-layer foundation for their React Native migration of the mobile app (previously two separate native codebases, 90+ screens).

Key adaptation:

  • Same Renderer abstraction — the "supercharged React components with observability, metrics, traces, data fetching, caching, state management, and analytics built-in" primitive is reused on RN. Zalando reports that integrating the web-era framework into RN yielded a production-ready setup with live data access in "just a few weeks."
  • Cross-platform UI substrate — paired with react-strict-dom + StyleX for HTML-subset cross-platform UI (see patterns/html-subset-to-native-ui-mapping) and Metro file resolution for per-platform escape hatches.
  • Brownfield integration via RN-as-a-package — Rendering Engine + RN run inside a Framework SDK that the legacy native app links as any other framework.
  • Validated screenDiscovery Feed, the media-heavy front screen, is one of the migrated RN surfaces.

This extension validates a multi-year architectural bet: the Renderer abstraction designed in 2018 for web-era micro- frontends turned out to be portable to mobile without being restructured. "Reusing our Rendering Engine allowed us to share core functionalities and code across both app and web platforms" — canonical end-state of the Interface-Framework unification posture.

Seen in

As the backend for Appcraft

The 2024-05-15 Appcraft retrospective () explicitly references the 2021-09 micro-frontends part-2 post (= this Rendering Engine's architecture post) as "the backend system that empowers this platform".

The connection is the custom React reconciler path documented in part 2 — for Native Apps, the same Renderer React element tree is consumed by a custom reconciler that emits app-compatible JSON instead of HTML. That JSON is precisely the wire format that Appcraft consumes on iOS + Android, where it is rendered via Flex on top of Texture / Litho into native UIKit / RecyclerView objects.

So the Rendering Engine is bidirectional:

  • Web surface: standard React renderer → HTML (streamed SSR) → browser hydration.
  • Mobile surface: custom React reconciler → JSON → Appcraft client runtime → Texture / Litho → native UI.

This places Appcraft as the mobile-native consumer of the JSON wire format the Rendering Engine produces; both share the same Renderer authoring model on the server side.

Last updated · 542 distilled / 1,571 read