Skip to content
Adam Johnson edited this page Jul 16, 2025 · 6 revisions

Implementing Lit SSR: Gotchas and Best Practices

This document recounts the experiences and challenges encountered while implementing Lit Server-Side Rendering (SSR) in this repository. Our primary goal was to improve page load performance and prevent Cumulative Layout Shift (CLS), especially for "above the fold" content.

Here are the primary "gotchas" we encountered and the best practices we developed to address them.

1. Isomorphic JavaScript and Browser-only APIs

One of the first and most common hurdles was writing "isomorphic" JavaScript that can run in both the Node.js server environment and the browser. Component code frequently accesses browser-specific global objects that don't exist on the server.

  • Problem: Code that accesses window, document, MutationObserver, IntersectionObserver, etc. or relies on browser-specific APIs like getComputedStyle() will fail during the server render.
  • Best Practice: Element authors must defensively guard any code that relies on browser-only APIs. A common pattern is to check lit's isServer flag
if (!isServer) { /* browser-only logic */ }

2. Asynchronous Work in Synchronous SSR

A significant architectural challenge was performing asynchronous setup work within Lit's synchronous SSR routine. Many components need to fetch data or perform other async tasks during their initialization, which doesn't naturally fit into the SSR model, since lit ssr does not support async functions.

  • Problem: There is currently no built-in Lit framework solution for handling asynchronous tasks during server rendering.
  • Our Solution: We implemented a custom solution that leverages the fact that Lit's SSR internals use iterators/generators. By creating an async iterator, we could effectively pause the rendering pipeline, wait for promises to resolve, and then yield the results back into the synchronous routine. This is a clever, but brittle, workaround. We eagerly await (🥁) an upstream solution.

3. Rehydration Mismatches

Rehydration is the process of client-side JavaScript taking control of the server-rendered DOM. A mismatch between the server-rendered HTML and the initial client-side render is a huge problem.

  • Problem: Mismatches are extremely difficult to debug. The error messages are often cryptic and don't point to the specific location in a component's template that caused the issue. In severe cases, a mismatch can prevent the component from ever updating on the client.
  • Best Practice: The responsibility for preventing mismatches lies with the element author. The component must treat the server-rendered state as a given and not attempt to modify it during its initial upgrade. The established pattern is to wait for the component to finish its first update cycle before allowing any client-side logic to run.
  • Code Example:
    updated() {
      if (!isServer && this.hasUpdated) {
        // It's now safe to run client-side-only logic
        // or modify the client side state
      }
    }

It's also crucial for the first two render cycles to not override any of the state set by the server.

4. Styling and Flash of Unstyled Content (FOUC)

Lit is quite good about collecting a component's static styles and including them in the server response, but we still encountered some styling issues.

  • Problem: If base theme styles are not present in the document, components can render without their intended theme, causing a "flash" of unstyled or improperly styled content.
  • Best Practice: Our convention of using CSS var() calls with built-in fallbacks mitigated most FOUC issues. However, we must coordinate with teams using SSR to ensure that a base set of theme styles is always included in the host page.
  • Optimization: We were able to optimize the payload by adding a step to minify the collected CSS strings during the SSR routine before they are inlined.

5. Complex Properties and State Serialization

Passing initial state to components on the server can be tricky, especially when dealing with complex data types.

  • Problem: Serializing complex data types like Objects or Arrays into attributes is inconsistent and often leads to unexpected behavior. We also observed strange effects when passing object references as Lit context values between components (e.g., a "state" object from a tab container to its tabs).
  • Best Practice: By convention, we avoid using complex attribute values. For sharing state, we found that setting up multiple Lit contexts, each for a single scalar value (like a string or boolean), was far more consistent and reliable than using a single context with a complex object.

6. The Lit Context API Trade-off

The recent addition of Lit context support in the SSR package is very helpful, but it comes with a significant trade-off.

  • Problem: Enabling full context support involves allowing components to run their connectedCallback on the server, which is necessary for context providers to register themselves before consumers need the value. However, this can break any component that performs direct DOM manipulation or inspection in its connectedCallback. The "late upgrading middle context provider" problem can also still occur, which is typically mitigated with the context-root pattern.
  • Best Practice: Adopting the new context support was beneficial, but it required a major refactor of our entire element library to move all DOM-related logic out of connectedCallback and into other lifecycle methods like firstUpdated. This is a critical consideration for any team looking to enable this feature.

7. SSR and Slotted Content

A significant technical limitation of the Lit SSR process is that it cannot dynamically react to the presence or absence of slotted children. Many components are designed to alter their own template based on what content is slotted into them (e.g., hiding a header slot if no header content is provided).

  • Problem: The server-render cannot inspect the children that will be slotted into a component, so it cannot conditionally render parts of its own template based on them. This I'd a hard technical limit based ultimately in performance concerns (enabling streaming HTML)
  • Best Practice: We developed an "isomorphic slot controller" to work around this limitation. This controller allows authors to declaratively signal the presence of slotted content via special ssr-hint attributes on the component host. During the server render, the component can read these ssr-hint-has-slotted attributes and adjust its template accordingly, ensuring the server-rendered output matches the final client-side appearance.

8. Infrastructure and CMS Integration Challenges

Integrating a Node.js-based SSR service into an existing enterprise ecosystem, such as a Drupal-based CMS, presents its own set of challenges related to infrastructure, deployment, and inter-team communication.

  • Architectural Mismatch: A core challenge arises from the differing architectural patterns. Modern web components are dynamic and render based on their immediate state and attributes. In contrast, many traditional CMSs are optimized for a more static, template-based approach (e.g., Twig). A request to provide a single, static Twig template for each component is impractical, as a component's rendered output can vary significantly based on its inputs. Generating a unique template for every possible attribute combination is not a scalable solution.
  • Infrastructure Considerations: Our initial exploration into a serverless (cloud edge) function model proved to be a concern due to potential cost at scale. A subsequent proposal to introduce a dedicated, containerized Node.js service to handle SSR requests was carefully considered. However, integrating a new service into existing, high-traffic production pipelines requires careful planning and risk assessment to ensure operational stability.
  • Path Forward: A comprehensive, integrated solution is still under discussion. As a potential stopgap, we believe a static template approach could be viable in specific highly controlled situations: i.e. components whose content is largely static and only requires direct interpolation of attribute values. This would not cover all components, and cannot be adopted across the design system, but could provide incremental performance benefits while a more holistic solution is developed.

Previous notes below

Conditional templates

Putting ternaries or conditional in templates, particularly in child parts but also in the element's top level template, can break hydration

Uncaught (in promise) Error: Hydration value mismatch: Unexpected TemplateResult rendered to part
    m hydrate-lit-html.js:6
    f hydrate-lit-html.js:6
    update lit-element-hydrate-support.js:1
    performUpdate reactive-element.ts:1441
    scheduleUpdate reactive-element.ts:1338
    _$ET reactive-element.ts:1310
    requestUpdate reactive-element.ts:1268
    _$Ev reactive-element.ts:1017
    b reactive-element.ts:1000
    h lit-element.ts:122
    RhIcon rh-icon.js:47
    t custom-element.ts:60
    __decorate tslib.es6.mjs:58
    <anonymous> rh-icon.ts:58

If you get errors like those, try to replace ternaries with ?hidden. You can use role="none" to remove default semantics from landmark elements like <footer> as well

Konnor Rogers says:

I've found for conditionals based on isServer you need an hasUpdated check has well. The first hydration is very picky.

Justin Fagnani says:

Oh, man... I see why this is now, but that's a pain. There's a first hydration data issue...

const _isServer = !this.hasUpdated && isServer

classMap({ isServer: _isServer })

Clone this wiki locally