Load JavaScript only when needed โ on click, hover, idle time, or when a block enters the viewport. Guide with working demos, caching patterns, prefetch, and framework recipes.
Lazy loading JavaScript (on-demand / just-in-time loading) means the code is not downloaded during the first paint. It starts only after a trigger: click, hover, scroll into view, route change, or browser idle time.
That cuts the critical path: fewer bytes on first load, better LCP/FCP/INP, and users never pay for features they never open (charts, editors, date libraries, maps).
Smaller main bundle. Only the shell and above-the-fold logic ship first.
Saves data and battery. Heavy libs (moment, charts, maps) wait for intent.
Admin tools, export, rich editors load only for the users who open them.
Less main-thread work on load improves LCP, FCP and often INP.
ES import() โ default choice for modules and bundlers.
createElement('script') โ CDN UMD builds, legacy browsers.
Start download when a widget is about to become visible.
React.lazy, Vue defineAsyncComponent, Angular loadChildren, next/dynamic.
Modern standard. import() returns a Promise and works in Chrome 63+, Firefox 67+,
Safari 14.1+, Edge 79+. Bundlers (Vite, Webpack, Rollup) split the imported file into its own chunk.
// โ Static import โ always in the first bundle
import moment from 'moment';
// โ
Loaded only after click
document.getElementById('loadBtn').addEventListener('click', async () => {
const { default: moment } = await import('moment');
console.log(moment().format('DD.MM.YYYY HH:mm:ss'));
});
Do not call import() from scratch on every click. Store the Promise.
The second call reuses the same module instance โ no extra network request.
let momentPromise;
function loadMoment() {
if (!momentPromise) {
momentPromise = import('https://cdn.jsdelivr.net/npm/moment@2.29.4/+esm');
}
return momentPromise;
}
btn.addEventListener('click', async () => {
const mod = await loadMoment();
const moment = mod.default || mod;
console.log(moment().format());
});
async function loadLibraries() {
const [lodash, axios] = await Promise.all([
import('lodash'),
import('axios')
]);
return {
chunks: lodash.chunk([1,2,3,4,5,6], 2),
data: await axios.get('/api/data')
};
}
btn.addEventListener('click', async () => {
result.textContent = 'โณ Loading...';
try {
const module = await import('./heavy-module.js');
result.textContent = 'โ
' + module.doHeavyWork();
} catch (error) {
result.textContent = 'โ ' + error.message;
}
});
Universal approach for UMD / IIFE scripts from a CDN. Works even where ESM import()
from a URL is blocked. Deduplicate by checking an existing <script src>.
function loadScript(src) {
const existing = document.querySelector('script[src="' + src + '"]');
if (existing) return Promise.resolve();
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = () => reject(new Error('Failed: ' + src));
document.head.appendChild(script);
});
}
btn.addEventListener('click', async () => {
await loadScript('https://cdn.jsdelivr.net/npm/moment@2.29.4/min/moment.min.js');
console.log(moment().format());
});
Fetch the source as text and run it. Flexible, but unsafe for untrusted URLs.
Prefer import() or a script tag. If you must execute text, new Function
is slightly clearer than bare eval, but the risk is the same.
const response = await fetch('/trusted/widget.js');
const code = await response.text();
// โ ๏ธ only first-party / reviewed code
new Function(code)();
Never eval user input or third-party APIs. CSP often blocks eval entirely โ another reason to use dynamic import or script tags.
When a chart, map, or comments widget is below the fold, start the download a little
before it becomes visible (rootMargin). This is โlazy on scrollโ without
waiting for a click.
const target = document.getElementById('chart-slot');
const io = new IntersectionObserver(async ([entry], obs) => {
if (!entry.isIntersecting) return;
obs.unobserve(entry.target);
const { renderChart } = await import('./chart.js');
renderChart(entry.target);
}, { rootMargin: '200px 0px' });
io.observe(target);
The box below โloadsโ a fake module the first time it is intersecting.
Lazy does not mean โstart from zero on clickโ. You can warm the cache while the user is still deciding.
rel="preload" โ you know the file is needed on this page very soon.rel="prefetch" โ likely needed on the next navigation (low priority).requestIdleCallback โ start import when the main thread is free.<!-- HTML: warm cache without executing -->
<link rel="prefetch" href="/chunks/editor.js" as="script">
// Hover intent
btn.addEventListener('pointerenter', () => {
import('./editor.js'); // cache Promise for the later click
}, { once: true });
// Idle time
const ric = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
ric(() => { import('./rarely-used.js'); });
const Heavy = lazy(() => import('./Heavy'));
{show && (
<Suspense fallback={<p>Loadingโฆ</p>}>
<Heavy />
</Suspense>
)}
const Heavy = defineAsyncComponent({
loader: () => import('./Heavy.vue'),
delay: 200,
timeout: 10000
});
{
path: 'heavy',
loadChildren: () =>
import('./heavy/heavy.module')
.then(m => m.HeavyModule)
}
const Heavy = dynamic(
() => import('../Heavy'),
{ ssr: false, loading: () => <p>โฆ</p> }
);
In a real app you almost never import from a CDN. You write a static path so the bundler can emit a separate file and a content hash.
// Vite / Webpack: path must be statically analyzable
const { openEditor } = await import('./features/editor.ts');
// Named chunk (Webpack magic comment)
await import(
/* webpackChunkName: "charts" */ './charts.ts'
);
// Vite: manualChunks in build.rollupOptions if you need grouping
import(variable) with a fully dynamic URL โ bundlers cannot split that
reliably. Keep a static prefix: import(`./locales/${locale}.js`).
| Method | Support | Performance | Security | Best for |
|---|---|---|---|---|
| Dynamic Import | โ 98% | โ Excellent | โ Safe | App modules, bundlers |
| Script tag | โ 100% | ๐ก Good | โ Safe | CDN UMD, analytics |
| Intersection Observer | โ 97% | โ Excellent | โ Safe | Below-the-fold widgets |
| Prefetch / idle | โ High | โ Excellent | โ Safe | Predictable next action |
| Fetch + eval | โ 100% | ๐ก Good | โ Risky | Avoid in production |
| React.lazy / Vue async | โ 98% | โ Excellent | โ Safe | UI components / routes |
Default for first-party code. Script tags only for UMD CDNs.
One in-flight load per module. Reset only on hard failure if you retry.
Disable the button, announce status, restore on error.
Hover, focus, or idle โ not every chunk on every page.
Network + Performance panels, Lighthouse, real RUM (INP).
aria-busy on the host, keep focus after the widget mounts.