Lazy‑Loading StencilJS Components with the @Load Decorator
Learn how to split your StencilJS bundle at runtime using @Load, see a working parent‑child example, and verify the impact on initial load size and network behavior.
24 Feb 2026, 06:30 UTC

Problem: Large initial JavaScript bundle delays interactivity
\nWhen a StencilJS application bundles all components into a single www/build/app.js file, users must download and parse code for components that may never be viewed on the first screen. This inflates the initial payload and can increase time‑to‑interactive (TTI), especially on slower connections.
Takeaway: Use @Load to defer a component until it is needed
\nThe @Load() decorator tells the Stencil compiler to treat the decorated method as a dynamic import point. The returned promise loads the child component’s module only when the promise resolves, creating a separate lazy chunk. The parent can render a fallback UI while waiting, then display the child component once the promise fulfills.
How @Load works – mechanism overview
\n- \n
- Declare a method (or property) decorated with
@Load()in the parent component. - The method returns a promise that imports the child component’s class via
import(). - Stencil splits the imported module into its own JavaScript chunk named after the child’s tag.
- In the render method, conditionally render the child tag only after the promise resolves (e.g., by storing the loader in a state variable).
- While the promise is pending, show a fallback such as a spinner or placeholder.
Worked example: Lazy‑loading a detail panel
\nAssume we have a parent component my-app that shows a button. Clicking the button should reveal a my-detail component that is not needed for the initial view.
Parent component (my-app.tsx)
\nimport { Component, h, State, Load } from '@stencil/core';\n\n@Component({\n tag: 'my-app',\n styleUrl: 'my-app.css',\n})\nexport class MyApp {\n @State() detailLoader: any = undefined;\n\n // @Load marks this method as a split point\n @Load()\n async loadDetail() {\n // The dynamic import creates a separate chunk\n const module = await import('./my-detail');\n return module.MyDetail;\n }\n\n handleClick = async () => {\n // Trigger the loader; store the resolved class\n this.detailLoader = await this.loadDetail();\n }\n\n render() {\n return [\n Show details,\n // Fallback while loading\n this.detailLoader === undefined ?\n Click the button to load details :\n // Once resolved, render the lazy component\n \n ];\n }\n}\n\nChild component (my-detail.tsx)
\nimport { Component, h } from '@stencil/core';\n\n@Component({\n tag: 'my-detail',\n styleUrl: 'my-detail.css',\n})\nexport class MyDetail {\n render() {\n return (\n \n Detail Panel
\n This content is loaded lazily.
\n \n );\n }\n}\n\nVerifying the lazy chunk
\nAfter writing the example, run a production build and inspect the output:
\n- \n
- Build: Run
npm run build(oryarn build) in the project root. No special permissions are needed beyond normal npm access. \n - Check generated files: Open
www/build. You should see:\n- \n
app.js– the main bundle containingmy-appand shared code. \nmy-detail.js– a separate chunk named after the lazy‑loaded tag. \n
\n - Bundle size comparison: Run
npm run build -- --verbose. The console prints the size of each chunk. Verify thatapp.jsis smaller after adding@Load()compared to a build wheremy-detailis imported statically. \n - Runtime network check:\n
- \n
- Open the page in Chrome. \n
- Open DevTools → Network tab. \n
- Set throttling to “Slow 3G” (or use a real slow connection). \n
- Reload the page. Observe that only
app.js(and possibly CSS) loads initially. \n - Click the “Show details” button. \n
- Watch for a subsequent request to
my-detail.jsappearing after the click. \n - Ensure the fallback text (“Click the button to load details”) is replaced by the detail panel once the request finishes. \n
\n
Limitations and common mistakes
\n- \n
- Tag string must be correct: The lazy loader resolves to a component class; if the tag name used in the template (
<my-detail>) does not match the component’s@Component({ tag: 'my-detail' })definition, Stencil throws a runtime error and the fallback never appears. \n - Path correctness: The dynamic import path (
import('./my-detail')) is relative to the parent component’s file. Moving files without updating the path breaks the lazy load. \n - Over‑splitting: Applying
@Load()to many small components can create numerous tiny chunks, leading to a waterfall of requests that may offset the initial‑size gain. Reserve lazy loading for components that are genuinely off the critical path (e.g., modals, tabs, low‑priority widgets). \n - Missing fallback: If you render the child tag directly without awaiting the loader, Stencil will attempt to instantiate the component before the module is fetched, resulting in a blank placeholder or error. Always guard rendering with a check for the resolved loader (or use
async/awaitin render withawait this.detailLoaderand a loading state). \n - Server‑side rendering (SSR) considerations: Stencil’s prerendering does not wait for lazy imports; the lazy component will not be included in the static HTML. Ensure that lazy‑loaded content is acceptable to be client‑only, or provide a server‑rendered fallback. \n
Practical verification checklist
\n- \n
- Run
npm run buildand confirm a separate chunk file for each@Load()‑ed component. \n - Compare main bundle size before and after adding the decorator (verbose build output). \n
- In DevTools Network tab, verify that the lazy chunk loads only after the user interaction that triggers the loader. \n
- Check that the fallback UI is shown while the chunk is loading and replaced by the actual component once loading completes. \n
- Ensure no console errors about missing tags or failed imports. \n
By following the steps above, you can confidently use StencilJS’s @Load() decorator to split code, reduce initial payload, and improve perceived performance while being aware of the pitfalls that can undermine the benefit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.