๐Ÿš€ Lazy Loading JavaScript

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.

-50%
Initial Load Time Reduction
-60%
Bandwidth Saved
+45%
Lighthouse Performance Score
98%
Browser Support (Dynamic Import)

๐Ÿ“‘ Table of Contents

๐Ÿ“– What is Lazy Loading JavaScript?

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).

Rule of thumb: keep the first bundle for the current screen. Everything behind a click, a tab, or a fold belongs in a separate chunk.

๐ŸŽฏ Why Load Scripts on Click?

โšก

Faster Initial Load

Smaller main bundle. Only the shell and above-the-fold logic ship first.

๐Ÿ“ฑ

Better Mobile Experience

Saves data and battery. Heavy libs (moment, charts, maps) wait for intent.

๐ŸŽฏ

Optimized User Flow

Admin tools, export, rich editors load only for the users who open them.

๐Ÿ“Š

Better Core Web Vitals

Less main-thread work on load improves LCP, FCP and often INP.

๐Ÿ› ๏ธ Methods Overview

๐Ÿ“ฆ

Dynamic Import

ES import() โ€” default choice for modules and bundlers.

ES6+
๐Ÿ“

Script Tag Injection

createElement('script') โ€” CDN UMD builds, legacy browsers.

Vanilla
๐Ÿ‘๏ธ

Intersection Observer

Start download when a widget is about to become visible.

Viewport
โš›๏ธ

Framework Solutions

React.lazy, Vue defineAsyncComponent, Angular loadChildren, next/dynamic.

React Vue Angular

๐Ÿ“ฆ Method 1: Dynamic Import (ES Modules)

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.

Basic example

// โŒ 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'));
});

Cache the Promise (load once)

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());
});

Live Demo: Load Moment.js on Click

๐ŸŽฏ Click to load moment.js (ESM from CDN)
Click the button to load the library...
โšก Network tab: the ESM request appears only after the first click. Later clicks reuse the module.

Several libraries in parallel

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')
    };
}

Error handling + loading UI

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;
    }
});

๐Ÿ“ Method 2: Dynamic Script Tag Injection

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>.

Basic example with reuse

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());
});

Live Demo: Script tag

๐Ÿ“ Load UMD moment via createElement
Click to load the script...

๐Ÿ“ฅ Method 3: Fetch + eval()

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)();
โš ๏ธ Security

Never eval user input or third-party APIs. CSP often blocks eval entirely โ€” another reason to use dynamic import or script tags.

๐Ÿ‘๏ธ Method 4: Intersection Observer

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);
๐Ÿ‘๏ธ Simulated viewport load

The box below โ€œloadsโ€ a fake module the first time it is intersecting.

Scroll this box into view (already visible)โ€ฆ
Waiting for IntersectionObserverโ€ฆ

๐Ÿ”ฎ Prefetch, Preload & Idle

Lazy does not mean โ€œstart from zero on clickโ€. You can warm the cache while the user is still deciding.

<!-- 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'); });
๐Ÿ–ฑ๏ธ Hover to prefetch, click to use
Hover starts a fake prefetch timer. Click consumes it.

โš›๏ธ Framework Solutions

โš›๏ธ

React โ€” React.lazy()

const Heavy = lazy(() => import('./Heavy'));

{show && (
  <Suspense fallback={<p>Loadingโ€ฆ</p>}>
    <Heavy />
  </Suspense>
)}
๐ŸŸฉ

Vue โ€” defineAsyncComponent

const Heavy = defineAsyncComponent({
  loader: () => import('./Heavy.vue'),
  delay: 200,
  timeout: 10000
});
๐Ÿ…ฐ๏ธ

Angular โ€” loadChildren

{
  path: 'heavy',
  loadChildren: () =>
    import('./heavy/heavy.module')
      .then(m => m.HeavyModule)
}
๐Ÿ“ฆ

Next.js โ€” next/dynamic

const Heavy = dynamic(
  () => import('../Heavy'),
  { ssr: false, loading: () => <p>โ€ฆ</p> }
);

๐Ÿงฉ Vite / Webpack code splitting

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
Avoid import(variable) with a fully dynamic URL โ€” bundlers cannot split that reliably. Keep a static prefix: import(`./locales/${locale}.js`).

๐Ÿ“Š Comparison Table

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

โœ… Best Practices

๐ŸŽฏ

Prefer import()

Default for first-party code. Script tags only for UMD CDNs.

๐Ÿ”„

Cache the Promise

One in-flight load per module. Reset only on hard failure if you retry.

๐Ÿ“ฑ

Show loading state

Disable the button, announce status, restore on error.

โšก

Prefetch on intent

Hover, focus, or idle โ€” not every chunk on every page.

๐Ÿงช

Measure

Network + Performance panels, Lighthouse, real RUM (INP).

โ™ฟ

Accessibility

aria-busy on the host, keep focus after the widget mounts.

๐Ÿ“Š Performance Comparison

๐Ÿ“Š 50 KB library: first load vs click
Initial Page Load0 KB
0%
After Click (on-demand)0 KB
0%
Click the button to see the performance impact