Lazy Loading
Components marked with static lazy = true are loaded only when they are first visible. The SSR-rendered HTML is visible immediately. The JavaScript module is fetched in the background when the user scrolls near the component, opens the tab panel it sits in, or opens the dialog that holds it.
When to use
- Below-the-fold components that don't need interactivity on initial load (charts, comment threads, image galleries).
- Heavy components with large dependencies that would slow down the initial page load.
- Components that most users never scroll to (footer widgets, "load more" sections).
- Panels and dialogs that are hidden at first paint: a second tab of a tab strip, a settings dialog, an editor pane behind a "Code" tab. A
hiddenelement (or one inside a closed<dialog>) is not visible, so its module waits until the panel opens.
When NOT to use
- For above-the-fold components, since they need to be interactive immediately.
- For critical UI like navigation, auth forms, or CTAs, since these must hydrate eagerly.
- For tiny components with negligible JS cost, where the overhead of lazy loading isn't worth it.
Basic usage
Add static lazy = true to your component class:
import { WebComponent, html, css } from '@webjsdev/core';
class HeavyChart extends WebComponent {
static lazy = true; // ← module loaded on scroll, not on page load
static styles = css`:host { display: block; min-height: 400px; }`;
render() {
return html`<canvas></canvas>`;
}
}
HeavyChart.register('heavy-chart'); How it works
- During SSR, the component is rendered normally as full HTML with Declarative Shadow DOM. The user sees the content immediately.
- The SSR pipeline skips the
<link rel="modulepreload">for lazy components (no eager download). - Instead, the tag is registered with the lazy loader: by a small inline script for a component the page rendered, and by the importing module itself when another component imports it (see below).
- An
IntersectionObserver(with 200px root margin) watches for the element. - When the element enters the viewport, the module is fetched via
import(). - The custom element class registers itself, upgrading the element so event listeners bind and state initializes.
Importing a lazy component
A lazy component is usually imported by the component that renders it, for example a workspace shell that renders its editor pane hidden until the user opens the Code tab. That import stays lazy. The server keeps it, so SSR renders the pane and knows it is lazy, and the browser copy of the shell is served with that one line rewritten to a lazy-loader registration:
// components/workspace-shell.ts, as you write it
import './code-pane.ts';
// as the browser receives it
import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy({ 'code-pane': '/components/code-pane.ts' })); The shell no longer waits for the pane (or anything only the pane imports) before it runs, and no modulepreload hint is emitted for that subtree. The pane loads the first time a <code-pane> is visible, including one the shell renders later on the client.
Only a side-effect import is deferred. A binding import such as import { CodePane } from './code-pane.ts' stays eager, because the importer needs the value when it runs, and then the component is eager everywhere (it keeps its preload hint). Code that calls a method on the element should allow for it not being upgraded yet (this.pane?.save?.(), or await customElements.whenDefined('code-pane') once the element is visible).
Browser tests (webjs test --browser) are served every import as written, so a test that imports a lazy component has it defined as soon as the import resolves.
The loader looks for lazy tags in the light DOM, so a lazy tag rendered inside another component's shadow root is not found. Render it in light DOM (the default) or import it eagerly.
Selective hydration
For even more control, use static hydrate = 'visible'. This defers the component's connectedCallback activation (not just the module load) until the element is visible:
class LazyComments extends WebComponent {
static hydrate = 'visible'; // ← activation deferred until visible
// ...
} The difference: lazy defers the module download. hydrate = 'visible' defers the component's activation even if the module is already loaded. Use both together for maximum deferral.
MutationObserver
The lazy loader automatically watches for dynamically added elements via MutationObserver. If a lazy component is added to the DOM after page load (e.g. via client-side navigation), it will still be observed and loaded when visible.
Fallback
In environments without IntersectionObserver (SSR-only, older browsers), all lazy components are loaded immediately as graceful degradation.
Next steps
- Components: component lifecycle and properties
- Server-Side Rendering: how DSD preserves visual content before hydration
- Lifecycle Hooks:
connectedCallback,firstUpdated, etc.