Optimizing StencilJS Apps with Automatic Lazy‑Loading: A Practical Guide
Learn how StencilJS automatically code‑splits components with dynamic imports, the tiny runtime loader it generates, and practical tips to balance bundle size and first‑paint performance.
19 Sept 2026, 22:22 UTC

Problem: Heavy Bundles Drag Down Time‑to‑Interactive
When a Stencil project grows beyond a handful of components, the default monolithic bundle can balloon to 200 KB+ gzipped. On a 3G network that translates to a noticeable delay before the first paint, especially for users who only need a header and a footer. Developers often resort to manual code‑splitting, but that introduces maintenance overhead and a higher risk of breaking the component registry.
How Stencil Handles Lazy‑Loading
Stencil’s compiler builds a dependency graph of all custom elements. For each component it emits a tiny entry module that looks like this (simplified):
export function defineCustomElement() {
return import('./my-component.entry.js').then(m => {
customElements.define('my-component', m.MyComponent);
return m.MyComponent;
});
}
export default defineCustomElement();
The entry file uses native import(), which is an ES2020 feature. When the element is first instantiated on the page, the browser fetches the component’s own chunk, upgrades the element, and runs its connectedCallback. No global registry or manual import() statements are required.
Key points:
- Each component becomes its own JavaScript chunk (≈1.5 KB gzipped for the loader).
- CSS is emitted as a separate chunk and injected via constructable stylesheets when available.
- Output targets (dist, dist‑react, etc.) preserve this contract.
The Generated Loader in Action
Let’s walk through a minimal example.
- Create a new Stencil project:
npm init stencil my-app cd my-app npm install - Add two components:
npm run generate component header npm run generate component footer - Build the app:
npm run build -- --docs - Inspect
dist/header/entry.js– you’ll see a dynamicimport()call toheader.entry.jsand thedefineCustomElement()wrapper. - Open
index.htmlin a browser, open DevTools, and go to the Network tab. Load the page normally; only the core bundle and theheaderchunk are requested. Thefooterchunk is fetched only when you add<my-footer></my-footer>to the DOM.
To verify that the component loads lazily, toggle a boolean in src/index.tsx:
{showFooter && <my-footer></my-footer>}
When showFooter becomes true at runtime, the footer chunk appears in the Network panel.
Trade‑offs & Practical Tips
- ES2020 Requirement:
import()is only available in modern browsers. For legacy browsers, include@stencil/core/polyfillsor a polyfill.io bundle (~4 KB gzipped). Without it, the lazy‑load will fail. - First‑Use Latency: The initial network round‑trip for a component chunk can add 200–300 ms on 3G. Mitigate with
<link rel="modulepreload" href="my-component.js">hints or Service Worker precaching. - Critical Above‑the‑Fold Elements: If a component must paint synchronously, opt‑out:
This trades bundle size for zero‑delay first render.@Component({ tag: 'my-header', lazyLoad: false }) export class MyHeader { /* … */ } - Constructable Stylesheets: When supported, Stencil injects CSS via
new CSSStyleSheet(), avoiding render‑blocking<link>tags. On older browsers, a brief flash of unstyled content may occur before the stylesheet loads. - Output Targets: The
@stencil/react-output-targetpreserves lazy loading. The generated React wrapper waits fordefineCustomElement()to resolve before forwarding props.
Conclusion
Stencil’s automatic lazy‑loading is a powerful feature that keeps production bundles lean and improves Time‑to‑Interactive on constrained networks. By understanding the compiler’s output, using preload hints, and selectively disabling lazy‑loading for critical components, you can strike the right balance between bundle size and perceived performance.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.